diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 28837e28..14fa32cd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,7 @@ ### Changed ### Fixed +- Fixed wrapped UI text measurement so automatic heights include every rendered line. ### Removed diff --git a/selene-core/src/ui/layout.mbt b/selene-core/src/ui/layout.mbt index badc10c1..f4ae3060 100644 --- a/selene-core/src/ui/layout.mbt +++ b/selene-core/src/ui/layout.mbt @@ -281,25 +281,55 @@ fn implicit_root_style(size : @math.Vec2) -> @style.Style { ///| fn measure_node( known_dimensions : @geometry.Size[Double?], - _available : @geometry.Size[@geometry.AvailableSpace], + available : @geometry.Size[@geometry.AvailableSpace], _node_id : Int, entity : @entity.Entity?, - _style : @style.Style, + style : @style.Style, ) -> @geometry.Size[Double] { guard entity is Some(entity) else { return @geometry.Size::zero() } let (intrinsic_width, intrinsic_height) = if texts().get(entity) is Some(text) { - let measured = @platform_render.measure_text( - text_style_for(entity).unwrap_or({ - font: None, - family: "Arial", - size: 16.0, - color: rgba(255, 255, 255, 1.0), - align: Left, - baseline: Top, - }), + let text_style = text_style_for(entity).unwrap_or({ + font: None, + family: "Arial", + size: 16.0, + color: rgba(255, 255, 255, 1.0), + align: Left, + baseline: Top, + }) + let width_basis = known_dimensions.width.unwrap_or( + match available.width { + AvailDefinite(width) => width + _ => 0.0 + }, + ) + let horizontal_inset = @util.resolve_dimension_width_basis( + style.padding.left, + width_basis, + ) + + @util.resolve_dimension_width_basis(style.padding.right, width_basis) + + @util.resolve_dimension_width_basis(style.border.left, width_basis) + + @util.resolve_dimension_width_basis(style.border.right, width_basis) + let available_content_width = match known_dimensions.width { + Some(width) => + @style_helpers.available_space_definite( + @cmp.maximum(0.0, width - horizontal_inset), + ) + None => + match available.width { + AvailDefinite(width) => + @style_helpers.available_space_definite( + @cmp.maximum(0.0, width - horizontal_inset), + ) + constraint => constraint + } + } + let prepared = prepare_text_layout( text.content, + text_style, + text_layout(entity), + available_content_width, ) - (measured[X], measured[Y]) + (prepared.width, prepared.height) } else if ui_images().get(entity) is Some(image) { (image.size[X], image.size[Y]) } else { diff --git a/selene-core/src/ui/layout_wbtest.mbt b/selene-core/src/ui/layout_wbtest.mbt index 596ac7db..07045d60 100644 --- a/selene-core/src/ui/layout_wbtest.mbt +++ b/selene-core/src/ui/layout_wbtest.mbt @@ -101,6 +101,48 @@ test "ui nodes map block display and aspect ratio into block layout" { inspect(layout.location.y == 15.0, content="true") } +///| +test "ui wrapped text contributes lines to auto height" { + let entity = @entity.Entity() + texts().set(entity, Text("hello world")) + text_fonts().set(entity, TextFont(size=16.0)) + text_layouts().set(entity, TextLayout(wrap=WordWrap)) + + let measured = measure_node( + Size(width=Some(60.0), height=None), + Size( + width=@style_helpers.available_space_definite(60.0), + height=@style_helpers.max_content(), + ), + 0, + Some(entity), + node_to_taffy_style(Node(width=Val::px(60.0))), + ) + + inspect(measured.width, content="60") + inspect(measured.height, content="32") + + let measured_with_padding = measure_node( + Size(width=Some(100.0), height=None), + Size( + width=@style_helpers.available_space_definite(100.0), + height=@style_helpers.max_content(), + ), + 0, + Some(entity), + node_to_taffy_style( + Node( + width=Val::px(100.0), + padding=UiRectValues::px(left=20.0, right=20.0), + ), + ), + ) + + inspect(measured_with_padding.width, content="100") + inspect(measured_with_padding.height, content="32") + cleanup_layout_test_entity(entity) +} + ///| test "ui nodes map grid placement and self alignment into grid layout" { let taffy : @tree.TaffyTree[Unit] = TaffyTree() diff --git a/selene-core/src/ui/moon.pkg b/selene-core/src/ui/moon.pkg index e50583d6..89b0739f 100644 --- a/selene-core/src/ui/moon.pkg +++ b/selene-core/src/ui/moon.pkg @@ -17,6 +17,7 @@ import { "Milky2018/moon_taffy/style", "Milky2018/moon_taffy/style_helpers", "Milky2018/moon_taffy/tree", + "Milky2018/moon_taffy/util", "moonbitlang/core/cmp", "moonbitlang/core/string", } diff --git a/selene-core/src/ui/render.mbt b/selene-core/src/ui/render.mbt index e5f5ad44..8944ae7e 100644 --- a/selene-core/src/ui/render.mbt +++ b/selene-core/src/ui/render.mbt @@ -715,38 +715,29 @@ fn push_image( } ///| -fn measure_text_fragment(entity : @entity.Entity, text : String) -> @math.Vec2 { - @platform_render.measure_text( - render_text_style(entity).unwrap_or( - scaled_text_style({ - font: None, - family: "Arial", - size: 16.0, - color: rgba(255, 255, 255, 1.0), - align: Left, - baseline: Top, - }), - ), - text, - ) +fn measure_text_fragment( + style : @render2d_types.TextStyle2D, + text : String, +) -> @math.Vec2 { + @platform_render.measure_text(style, text) } ///| fn truncate_line( - entity : @entity.Entity, + style : @render2d_types.TextStyle2D, text : String, max_width : Double, ) -> String { if max_width <= 0.0 { return "" } - if measure_text_fragment(entity, text)[X] <= max_width { + if measure_text_fragment(style, text)[X] <= max_width { return text } let ellipsis = "..." let mut current = text while current.length() > 0 && - measure_text_fragment(entity, current + ellipsis)[X] > max_width { + measure_text_fragment(style, current + ellipsis)[X] > max_width { current = current.unsafe_substring(start=0, end=current.length() - 1) } current + ellipsis @@ -754,7 +745,7 @@ fn truncate_line( ///| fn wrap_line( - entity : @entity.Entity, + style : @render2d_types.TextStyle2D, text : String, max_width : Double, ) -> Array[String] { @@ -766,16 +757,16 @@ fn wrap_line( words.push(word.to_owned()) } if words.length() <= 1 { - return [truncate_line(entity, text, max_width)] + return [truncate_line(style, text, max_width)] } let lines : Array[String] = [] let mut current = "" for word in words { let candidate = if current == "" { word } else { current + " " + word } - if measure_text_fragment(entity, candidate)[X] <= max_width { + if measure_text_fragment(style, candidate)[X] <= max_width { current = candidate } else if current == "" { - lines.push(truncate_line(entity, word, max_width)) + lines.push(truncate_line(style, word, max_width)) } else { lines.push(current) current = word @@ -790,36 +781,11 @@ fn wrap_line( ///| fn prepared_text_lines( entity : @entity.Entity, - content_rect : @math.Rect, + available_width : @geometry.AvailableSpace, ) -> Array[String] { guard texts().get(entity) is Some(text) else { return [] } - let layout = text_layout(entity) - let raw_lines : Array[String] = [] - for raw_line in text.content.split("\n") { - raw_lines.push(raw_line.to_owned()) - } - let lines : Array[String] = [] - for raw_line in raw_lines { - match layout.wrap { - NoWrap => - lines.push( - match layout.overflow { - Clip => raw_line - Ellipsis => truncate_line(entity, raw_line, content_rect.size[X]) - }, - ) - WordWrap => - for line in wrap_line(entity, raw_line, content_rect.size[X]) { - lines.push( - match layout.overflow { - Clip => line - Ellipsis => truncate_line(entity, line, content_rect.size[X]) - }, - ) - } - } - } - lines + guard text_style_for(entity) is Some(style) else { return [] } + prepare_text_layout(text.content, style, text_layout(entity), available_width).lines } ///| @@ -828,7 +794,10 @@ fn push_text(entity : @entity.Entity, camera_entity_id? : UInt? = None) -> Unit guard global_ui_nodes().get(entity) is Some(transform) else { return } guard render_text_style(entity) is Some(style) else { return } let layout = text_layout(entity) - let lines = prepared_text_lines(entity, transform.screen_content_rect) + let lines = prepared_text_lines( + entity, + AvailDefinite(transform.logical_content_rect.size[X]), + ) if lines.is_empty() { return } @@ -845,7 +814,7 @@ fn push_text(entity : @entity.Entity, camera_entity_id? : UInt? = None) -> Unit block_height } for index, line in lines { - let measured = measure_text_fragment(entity, line) + let measured = measure_text_fragment(style, line) let x = match layout.align { Left => transform.screen_content_rect.position[X] Center => diff --git a/selene-core/src/ui/text_layout.mbt b/selene-core/src/ui/text_layout.mbt new file mode 100644 index 00000000..cef71643 --- /dev/null +++ b/selene-core/src/ui/text_layout.mbt @@ -0,0 +1,61 @@ +///| +priv struct PreparedTextLayout { + lines : Array[String] + width : Double + height : Double +} + +///| +fn prepare_text_layout( + content : String, + style : @render2d_types.TextStyle2D, + layout : TextLayout, + available_width : @geometry.AvailableSpace, +) -> PreparedTextLayout { + let wrap_width : Double? = match available_width { + AvailDefinite(width) => Some(@cmp.maximum(0.0, width)) + AvailMaxContent => None + AvailMinContent => { + let mut longest = 0.0 + for raw_line in content.split("\n") { + for word in raw_line.split(" ") { + longest = @cmp.maximum( + longest, + measure_text_fragment(style, word.to_owned())[X], + ) + } + } + Some(longest) + } + } + let lines : Array[String] = [] + for raw_line_view in content.split("\n") { + let raw_line = raw_line_view.to_owned() + match (layout.wrap, wrap_width) { + (WordWrap, Some(max_width)) => + for line in wrap_line(style, raw_line, max_width) { + lines.push( + match (layout.overflow, available_width) { + (Ellipsis, AvailDefinite(_)) => + truncate_line(style, line, max_width) + _ => line + }, + ) + } + _ => + lines.push( + match (layout.overflow, available_width) { + (Ellipsis, AvailDefinite(max_width)) => + truncate_line(style, raw_line, max_width) + _ => raw_line + }, + ) + } + } + let mut width = 0.0 + for line in lines { + width = @cmp.maximum(width, measure_text_fragment(style, line)[X]) + } + let line_height = style.size * @cmp.maximum(1.0, layout.line_height) + { lines, width, height: line_height * lines.length().to_double() } +}