// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
pub struct TextHandle {
id : Int
}
///|
pub fn TextHandle::new(id : Int) -> TextHandle {
TextHandle::{ id, }
}
///|
pub fn TextHandle::id(self : TextHandle) -> Int {
self.id
}
///|
pub struct TextSpanHandle {
id : Int
}
///|
pub fn TextSpanHandle::new(id : Int) -> TextSpanHandle {
TextSpanHandle::{ id, }
}
///|
pub struct Text2dBundle {
text : Text2d
font : TextFont
color : TextColor
layout : TextLayout
bounds : TextBounds
line_height : LineHeight
shadow : Text2dShadow?
transform : @math.Transform
layers : @render2d.RenderLayers
anchor : @render2d.Anchor
y_axis : YAxisOrientation
}
///|
pub fn Text2dBundle::new(
text : Text2d,
font : TextFont,
transform : @math.Transform,
) -> Text2dBundle {
Text2dBundle::{
text,
font,
color: TextColor::default(),
layout: TextLayout::default(),
bounds: TextBounds::default(),
line_height: LineHeight::default(),
shadow: None,
transform,
layers: @render2d.RenderLayers::default(),
anchor: @render2d.Anchor::default(),
y_axis: YAxisOrientation::BottomToTop,
}
}
///|
pub fn Text2dBundle::new_with_layers(
text : Text2d,
font : TextFont,
transform : @math.Transform,
layers : @render2d.RenderLayers,
) -> Text2dBundle {
Text2dBundle::{
text,
font,
color: TextColor::default(),
layout: TextLayout::default(),
bounds: TextBounds::default(),
line_height: LineHeight::default(),
shadow: None,
transform,
layers,
anchor: @render2d.Anchor::default(),
y_axis: YAxisOrientation::BottomToTop,
}
}
///|
pub fn Text2dBundle::with_color(
self : Text2dBundle,
color : TextColor,
) -> Text2dBundle {
Text2dBundle::{ ..self, color, }
}
///|
pub fn Text2dBundle::with_layout(
self : Text2dBundle,
layout : TextLayout,
) -> Text2dBundle {
Text2dBundle::{ ..self, layout, }
}
///|
pub fn Text2dBundle::with_bounds(
self : Text2dBundle,
bounds : TextBounds,
) -> Text2dBundle {
Text2dBundle::{ ..self, bounds, }
}
///|
pub fn Text2dBundle::with_line_height(
self : Text2dBundle,
line_height : LineHeight,
) -> Text2dBundle {
Text2dBundle::{ ..self, line_height, }
}
///|
pub fn Text2dBundle::with_shadow(
self : Text2dBundle,
shadow : Text2dShadow?,
) -> Text2dBundle {
Text2dBundle::{ ..self, shadow, }
}
///|
/// Set the anchor used for world-space `Text2d` positioning (Bevy-aligned).
pub fn Text2dBundle::with_anchor(
self : Text2dBundle,
anchor : @render2d.Anchor,
) -> Text2dBundle {
Text2dBundle::{ ..self, anchor, }
}
///|
/// Set the layout coordinate orientation.
pub fn Text2dBundle::with_y_axis(
self : Text2dBundle,
y_axis : YAxisOrientation,
) -> Text2dBundle {
Text2dBundle::{ ..self, y_axis, }
}
///|
pub fn Text2dBundle::spawn(self : Text2dBundle) -> TextHandle {
text_spawn(self)
}
///|
pub struct TextSpanBundle {
span : TextSpan
font : TextFont
color : TextColor
line_height : LineHeight
}
///|
pub fn TextSpanBundle::new(span : TextSpan, font : TextFont) -> TextSpanBundle {
TextSpanBundle::{
span,
font,
color: TextColor::default(),
line_height: LineHeight::default(),
}
}
///|
pub fn TextSpanBundle::spawn(
self : TextSpanBundle,
parent : TextHandle,
) -> TextSpanHandle {
text_spawn_span(parent, self)
}
///|
priv struct TextSpanInstance {
id : Int
span : TextSpan
font : TextFont
color : TextColor
line_height : LineHeight
}
///|
priv struct TextInstance {
id : Int
text : Text2d
font : TextFont
color : TextColor
layout : TextLayout
bounds : TextBounds
line_height : LineHeight
shadow : Text2dShadow?
transform : @math.Transform
layers : @render2d.RenderLayers
anchor : @render2d.Anchor
y_axis : YAxisOrientation
/// When set, this text handle is treated as "UI text" and will be positioned
/// relative to a UI node's top-left corner (Bevy UI extraction semantics).
ui_node_size : @math.Vec2?
/// Optional UI clip rect (logical UI space) propagated from `ui_clip_system`.
/// When present, glyph sprites are submitted with scissor.
ui_scissor : @math.Rect?
spans : Array[TextSpanInstance]
computed : ComputedTextBlock
layout_info : TextLayoutInfo
}
///|
priv struct TextState {
mut next_text_id : Int
mut next_span_id : Int
texts : Array[TextInstance]
}
///|
let text_state : Ref[TextState] = Ref::new(TextState::{
next_text_id: 0,
next_span_id: 0,
texts: [],
})
///|
pub struct TextDebugGlyphDraw {
text_id : Int
bounds : @math.Rect
scissor : @math.Rect?
texture_id : Int
uv_min : @math.Vec2
uv_max : @math.Vec2
}
///|
pub struct TextDebugTextInfo {
text_id : Int
content : String
}
///|
priv struct TextDebugFrameState {
draws : Array[TextDebugGlyphDraw]
}
///|
let text_debug_frame_state : Ref[TextDebugFrameState] = Ref::new(TextDebugFrameState::{
draws: [],
})
///|
pub fn text_debug_clear_frame() -> Unit {
text_debug_frame_state.val.draws.clear()
}
///|
pub fn text_debug_push_glyph(
text_id : Int,
bounds : @math.Rect,
scissor : @math.Rect?,
texture_id : Int,
uv_min : @math.Vec2,
uv_max : @math.Vec2,
) -> Unit {
text_debug_frame_state.val.draws.push(TextDebugGlyphDraw::{
text_id,
bounds,
scissor,
texture_id,
uv_min,
uv_max,
})
}
///|
pub fn text_debug_draw_count(handle : TextHandle) -> Int {
let mut count = 0
for draw in text_debug_frame_state.val.draws {
if draw.text_id == handle.id {
count = count + 1
}
}
count
}
///|
pub fn text_debug_draws(handle : TextHandle) -> Array[TextDebugGlyphDraw] {
let draws : Array[TextDebugGlyphDraw] = []
for draw in text_debug_frame_state.val.draws {
if draw.text_id == handle.id {
draws.push(draw)
}
}
draws
}
///|
pub fn text_debug_all_draws() -> Array[TextDebugGlyphDraw] {
text_debug_frame_state.val.draws
}
///|
pub fn text_debug_text_infos() -> Array[TextDebugTextInfo] {
let infos : Array[TextDebugTextInfo] = []
for instance in text_state.val.texts {
infos.push(TextDebugTextInfo::{
text_id: instance.id,
content: instance.text.content,
})
}
infos
}
///|
fn text_debug_rects_intersect(a : @math.Rect, b : @math.Rect) -> Bool {
if a.max.x <= b.min.x || b.max.x <= a.min.x {
return false
}
if a.max.y <= b.min.y || b.max.y <= a.min.y {
return false
}
true
}
///|
fn text_debug_rect_intersection(a : @math.Rect, b : @math.Rect) -> @math.Rect {
let min_x = if a.min.x > b.min.x { a.min.x } else { b.min.x }
let min_y = if a.min.y > b.min.y { a.min.y } else { b.min.y }
let max_x = if a.max.x < b.max.x { a.max.x } else { b.max.x }
let max_y = if a.max.y < b.max.y { a.max.y } else { b.max.y }
@math.Rect::new(@math.Vec2::new(min_x, min_y), @math.Vec2::new(max_x, max_y))
}
///|
fn text_debug_rect_non_empty(rect : @math.Rect) -> Bool {
rect.max.x > rect.min.x && rect.max.y > rect.min.y
}
///|
fn text_debug_visible_bounds(draw : TextDebugGlyphDraw) -> @math.Rect? {
match draw.scissor {
None => Some(draw.bounds)
Some(scissor) => {
let clipped = text_debug_rect_intersection(draw.bounds, scissor)
if text_debug_rect_non_empty(clipped) {
Some(clipped)
} else {
None
}
}
}
}
///|
pub fn text_debug_overlap_count(lhs : TextHandle, rhs : TextHandle) -> Int {
let mut overlap = 0
for a in text_debug_frame_state.val.draws {
if a.text_id != lhs.id {
continue
}
for b in text_debug_frame_state.val.draws {
if b.text_id != rhs.id {
continue
}
if text_debug_rects_intersect(a.bounds, b.bounds) {
overlap = overlap + 1
}
}
}
overlap
}
///|
pub fn text_debug_visible_draw_count(handle : TextHandle) -> Int {
let mut count = 0
for draw in text_debug_frame_state.val.draws {
if draw.text_id != handle.id {
continue
}
if text_debug_visible_bounds(draw) is Some(_) {
count = count + 1
}
}
count
}
///|
pub fn text_debug_visible_overlap_count(
lhs : TextHandle,
rhs : TextHandle,
) -> Int {
let mut overlap = 0
for a in text_debug_frame_state.val.draws {
if a.text_id != lhs.id {
continue
}
let visible_a = text_debug_visible_bounds(a)
guard visible_a is Some(a_rect) else { continue }
for b in text_debug_frame_state.val.draws {
if b.text_id != rhs.id {
continue
}
let visible_b = text_debug_visible_bounds(b)
guard visible_b is Some(b_rect) else { continue }
if text_debug_rects_intersect(a_rect, b_rect) {
overlap = overlap + 1
}
}
}
overlap
}
///|
pub fn text_spawn(bundle : Text2dBundle) -> TextHandle {
let id = text_state.val.next_text_id
text_state.val.next_text_id = id + 1
text_state.val.texts.push(TextInstance::{
id,
text: bundle.text,
font: bundle.font,
color: bundle.color,
layout: bundle.layout,
bounds: bundle.bounds,
line_height: bundle.line_height,
shadow: bundle.shadow,
transform: bundle.transform,
layers: bundle.layers,
anchor: bundle.anchor,
y_axis: bundle.y_axis,
ui_node_size: None,
ui_scissor: None,
spans: [],
computed: ComputedTextBlock::default(),
layout_info: TextLayoutInfo::default(),
})
TextHandle::{ id, }
}
///|
pub fn text_spawn_span(
parent : TextHandle,
bundle : TextSpanBundle,
) -> TextSpanHandle {
let span_id = text_state.val.next_span_id
text_state.val.next_span_id = span_id + 1
let count = text_state.val.texts.length()
for i in 0.. TextInstance,
) -> Unit {
let count = text_state.val.texts.length()
for i in 0.. Unit {
text_update_instance(handle, instance => TextInstance::{
..instance,
text: Text2d::{ content, },
computed: ComputedTextBlock::{ ..instance.computed, needs_rerender: true },
})
}
///|
/// Update the transform of a text handle without marking it as needing rerender.
pub fn text_set_transform(
handle : TextHandle,
transform : @math.Transform,
) -> Unit {
text_update_instance(handle, instance => TextInstance::{
..instance,
transform,
})
}
///|
/// Get the current transform for a text handle.
pub fn text_get_transform(handle : TextHandle) -> @math.Transform? {
let count = text_state.val.texts.length()
for i in 0.. Unit {
text_update_instance(handle, instance => TextInstance::{ ..instance, layers, })
}
///|
/// Update the anchor of a text handle without marking it as needing rerender.
pub fn text_set_anchor(handle : TextHandle, anchor : @render2d.Anchor) -> Unit {
text_update_instance(handle, instance => TextInstance::{ ..instance, anchor, })
}
///|
/// Update the y-axis orientation of a text handle without marking it as needing rerender.
pub fn text_set_y_axis(handle : TextHandle, y_axis : YAxisOrientation) -> Unit {
// Orientation affects glyph coordinates, so we must relayout.
text_update_instance(handle, instance => {
if instance.y_axis == y_axis {
instance
} else {
TextInstance::{
..instance,
y_axis,
computed: ComputedTextBlock::{
..instance.computed,
needs_rerender: true,
},
}
}
})
}
///| Mark a text handle as UI text and provide the size of the owning UI node in logical pixels.
///|
///|
/// This does not affect shaping, but it changes how glyphs are positioned when rendered.
pub fn text_set_ui_node_size(handle : TextHandle, size : @math.Vec2?) -> Unit {
text_update_instance(handle, instance => TextInstance::{
..instance,
ui_node_size: size,
})
}
///|
/// Set optional UI clip rect for a text handle.
pub fn text_set_ui_scissor(handle : TextHandle, scissor : @math.Rect?) -> Unit {
text_update_instance(handle, instance => TextInstance::{
..instance,
ui_scissor: scissor,
})
}
///|
/// Get current optional UI clip rect for a text handle.
pub fn text_get_ui_scissor(handle : TextHandle) -> @math.Rect? {
let count = text_state.val.texts.length()
for i in 0.. @math.Vec2? {
let count = text_state.val.texts.length()
for i in 0.. String? {
let count = text_state.val.texts.length()
for i in 0.. @math.Rect? {
fn rotate_vec2(v : @math.Vec2, rot : Float) -> @math.Vec2 {
let cosv = @math.cos(rot)
let sinv = @math.sin(rot)
@math.Vec2::new(v.x * cosv - v.y * sinv, v.x * sinv + v.y * cosv)
}
fn to_y_up(y_axis : YAxisOrientation, v : @math.Vec2) -> @math.Vec2 {
match y_axis {
YAxisOrientation::TopToBottom => @math.Vec2::new(v.x, 0.0F - v.y)
YAxisOrientation::BottomToTop => v
}
}
fn absf(v : Float) -> Float {
if v < 0.0F {
0.0F - v
} else {
v
}
}
let count = text_state.val.texts.length()
for i in 0..
@math.Vec2::new(0.0F - node_size.x / 2.0F, 0.0F - node_size.y / 2.0F)
None => {
let anchor = instance.anchor.as_vec()
let text_anchor = @math.Vec2::new(
0.0F - (anchor.x + 0.5F),
0.0F - (anchor.y + 0.5F),
)
@math.Vec2::new(
layout_size.x * text_anchor.x,
layout_size.y * text_anchor.y,
)
}
}
let origin_offset = to_y_up(instance.y_axis, origin_offset_layout)
fn extend_with_point(
p : @math.Vec2,
min_x : Ref[Float],
min_y : Ref[Float],
max_x : Ref[Float],
max_y : Ref[Float],
initialized : Ref[Bool],
) -> Unit {
if !initialized.val {
min_x.val = p.x
min_y.val = p.y
max_x.val = p.x
max_y.val = p.y
initialized.val = true
return
}
if p.x < min_x.val {
min_x.val = p.x
}
if p.y < min_y.val {
min_y.val = p.y
}
if p.x > max_x.val {
max_x.val = p.x
}
if p.y > max_y.val {
max_y.val = p.y
}
}
let min_x_ref = Ref::new(0.0F)
let min_y_ref = Ref::new(0.0F)
let max_x_ref = Ref::new(0.0F)
let max_y_ref = Ref::new(0.0F)
let initialized_ref = Ref::new(false)
for glyph in glyphs {
let glyph_pos = to_y_up(instance.y_axis, glyph.position)
let local_pos = @math.Vec2::new(
origin_offset.x + glyph_pos.x,
origin_offset.y + glyph_pos.y,
)
let local_scaled = @math.Vec2::new(
local_pos.x * scale.x,
local_pos.y * scale.y,
)
let center_offset = rotate_vec2(local_scaled, rot)
let center = @math.Vec2::new(
base.x + center_offset.x,
base.y + center_offset.y,
)
let half_w = absf(glyph.size.x * scale.x) / 2.0F
let half_h = absf(glyph.size.y * scale.y) / 2.0F
let c0 = rotate_vec2(@math.Vec2::new(0.0F - half_w, 0.0F - half_h), rot)
let c1 = rotate_vec2(@math.Vec2::new(half_w, 0.0F - half_h), rot)
let c2 = rotate_vec2(@math.Vec2::new(half_w, half_h), rot)
let c3 = rotate_vec2(@math.Vec2::new(0.0F - half_w, half_h), rot)
extend_with_point(
@math.Vec2::new(center.x + c0.x, center.y + c0.y),
min_x_ref,
min_y_ref,
max_x_ref,
max_y_ref,
initialized_ref,
)
extend_with_point(
@math.Vec2::new(center.x + c1.x, center.y + c1.y),
min_x_ref,
min_y_ref,
max_x_ref,
max_y_ref,
initialized_ref,
)
extend_with_point(
@math.Vec2::new(center.x + c2.x, center.y + c2.y),
min_x_ref,
min_y_ref,
max_x_ref,
max_y_ref,
initialized_ref,
)
extend_with_point(
@math.Vec2::new(center.x + c3.x, center.y + c3.y),
min_x_ref,
min_y_ref,
max_x_ref,
max_y_ref,
initialized_ref,
)
}
if !initialized_ref.val {
return None
}
return Some(
@math.Rect::new(
@math.Vec2::new(min_x_ref.val, min_y_ref.val),
@math.Vec2::new(max_x_ref.val, max_y_ref.val),
),
)
}
None
}