Skip to main content

mittens_engine/engine/ecs/system/
text_system.rs

1use crate::engine::ecs::ComponentId;
2use crate::engine::ecs::World;
3use crate::engine::ecs::component::{
4    ColorComponent, EmissiveComponent, OpacityComponent, RaycastableComponent, RenderableComponent,
5    SerializeComponent, TextComponent, TextInputComponent, TextInputGlyphHitComponent,
6    TextShadowComponent, TextureComponent, TextureFilteringComponent, TransformComponent,
7    TransparentCutoutComponent, UVComponent,
8};
9use crate::engine::ecs::{EventSignal, IntentValue};
10use crate::engine::graphics::TextureFiltering;
11use crate::engine::graphics::VisualWorld;
12#[derive(Debug, Default)]
13pub struct TextSystem;
14#[derive(Debug, Clone, Copy)]
15struct WordWrapState {
16    col: usize,
17    row: usize,
18    /// Maximum column reached on any line (used for background width).
19    max_col: usize,
20    line_count: usize,
21    last_wrap_allowed: bool,
22    wrap_at: usize,
23    word_wrap: bool,
24    font_size: f32,
25}
26impl WordWrapState {
27    const TAB_WIDTH: usize = 4;
28    fn new(wrap_at: usize, word_wrap: bool, font_size: f32) -> Self {
29        Self {
30            col: 0,
31            row: 0,
32            max_col: 0,
33            line_count: 0,
34            last_wrap_allowed: false,
35            wrap_at,
36            word_wrap,
37            font_size,
38        }
39    }
40    fn newline(&mut self) {
41        self.max_col = self.max_col.max(self.col);
42        self.row += 1;
43        self.col = 0;
44        self.line_count = 0;
45        self.last_wrap_allowed = false;
46    }
47    fn apply_wrap_if_needed(&mut self) {
48        if self.wrap_at == 0 || self.line_count < self.wrap_at {
49            return;
50        }
51        // Word-wrap mode: only wrap if we previously encountered a wrap token.
52        // Otherwise keep going to avoid breaking words.
53        let should_wrap = if self.word_wrap {
54            self.last_wrap_allowed && self.col > 0
55        } else {
56            true
57        };
58        if should_wrap {
59            self.max_col = self.max_col.max(self.col);
60            self.row += 1;
61            self.col = 0;
62            self.line_count = 0;
63        }
64    }
65    fn cursor_pos(&self) -> (f32, f32) {
66        // The text wrapper origin is the top-left of the text block. Glyph
67        // quads remain center-origin'd inside that wrapper, so each glyph
68        // center sits half a cell inward from the block origin.
69        (
70            (self.col as f32 + 0.5) * self.font_size,
71            -((self.row as f32 + 0.5) * self.font_size),
72        )
73    }
74    fn advance_space(&mut self, i: usize, wrap_allowed_after: &[bool]) {
75        self.col += 1;
76        self.line_count += 1;
77        self.last_wrap_allowed = wrap_allowed_after.get(i).copied().unwrap_or(false);
78    }
79    fn advance_tab(&mut self, i: usize, wrap_allowed_after: &[bool]) {
80        self.col += Self::TAB_WIDTH;
81        self.line_count += Self::TAB_WIDTH;
82        self.last_wrap_allowed = wrap_allowed_after.get(i).copied().unwrap_or(false);
83    }
84    fn advance_glyph(&mut self, i: usize, wrap_allowed_after: &[bool]) {
85        self.col += 1;
86        self.line_count += 1;
87        self.last_wrap_allowed = wrap_allowed_after.get(i).copied().unwrap_or(false);
88    }
89    /// Word-wrap look-ahead. Called before each non-space glyph at index `i`
90    /// with the length of the unbreakable run starting at `i` (number of
91    /// chars until the next wrap-allowed position or end-of-text).
92    ///
93    /// If the upcoming word wouldn't fit on the current line and we just
94    /// passed a wrap opportunity, wrap *now* — `apply_wrap_if_needed` only
95    /// catches the overflow after it has already happened, which leaves the
96    /// trailing word sticking past the container.
97    fn apply_word_wrap_lookahead(&mut self, next_word_len: usize) {
98        if !self.word_wrap || self.wrap_at == 0 || self.col == 0 {
99            return;
100        }
101        if !self.last_wrap_allowed {
102            return;
103        }
104        if self.col + next_word_len > self.wrap_at {
105            self.max_col = self.max_col.max(self.col);
106            self.row += 1;
107            self.col = 0;
108            self.line_count = 0;
109            self.last_wrap_allowed = false;
110        }
111    }
112}
113/// For each index `i`, the number of chars from `i` until (and not
114/// including) the next wrap-allowed position, or `chars.len() - i` if no
115/// further break exists. This is the "word length" the look-ahead checks
116/// against `wrap_at` to decide whether to break at the preceding space.
117fn compute_word_run_len(wrap_allowed_after: &[bool]) -> Vec<usize> {
118    let n = wrap_allowed_after.len();
119    let mut out = vec![0; n];
120    let mut run = 0usize;
121    for i in (0..n).rev() {
122        if wrap_allowed_after[i] {
123            run = 0;
124        } else {
125            run += 1;
126        }
127        out[i] = run;
128    }
129    out
130}
131#[derive(Debug, Clone, Copy)]
132enum TextLayoutEvent {
133    BeforeChar {
134        index: usize,
135        ch: char,
136        state: WordWrapState,
137    },
138}
139#[derive(Debug, Clone, Copy)]
140pub struct SpawnedGlyph {
141    pub transform: ComponentId,
142    pub renderable: ComponentId,
143    pub uv: ComponentId,
144    pub texture: ComponentId,
145}
146impl TextSystem {
147    pub(crate) fn on_parent_changed(
148        world: &mut World,
149        emit: &mut dyn crate::engine::ecs::SignalEmitter,
150        env: &crate::engine::ecs::Signal,
151    ) {
152        let Some(EventSignal::ParentChanged {
153            child, new_parent, ..
154        }) = env.event.as_ref()
155        else {
156            return;
157        };
158        // Only care about style nodes being attached directly under a TextComponent root.
159        let Some(parent) = *new_parent else {
160            return;
161        };
162        if world
163            .get_component_by_id_as::<TextComponent>(parent)
164            .is_none()
165        {
166            return;
167        }
168        // Late-attached ColorComponent: trigger re-registration so existing glyph renderables
169        // update immediately.
170        if world
171            .get_component_by_id_as::<ColorComponent>(*child)
172            .is_some()
173        {
174            emit.push_intent_now(
175                *child,
176                IntentValue::RegisterColor {
177                    component_ids: vec![*child],
178                },
179            );
180        }
181    }
182    fn spawn_glyph_quad(
183        world: &mut World,
184        parent: ComponentId,
185        uvs: Vec<[f32; 2]>,
186        texture_uri: &str,
187        filtering: Option<TextureFiltering>,
188        emissive: Option<bool>,
189        raycastable: Option<RaycastableComponent>,
190        color_override: Option<[f32; 4]>,
191    ) -> (ComponentId, ComponentId, ComponentId) {
192        // Optional color override: insert a ColorComponent above the renderable.
193        let renderable_parent = if let Some(rgba) = color_override {
194            let c_id = world.add_component(ColorComponent { rgba });
195            let _ = world.add_child(parent, c_id);
196            c_id
197        } else {
198            parent
199        };
200        let r_id = world.add_component(RenderableComponent::square());
201        let _ = world.add_child(renderable_parent, r_id);
202        if let Some(rc) = raycastable {
203            let rc_id = world.add_component(rc);
204            let _ = world.add_child(r_id, rc_id);
205        }
206        let uv_id = world.add_component(UVComponent { uvs });
207        let _ = world.add_child(r_id, uv_id);
208        let tex_id = world.add_component(TextureComponent::with_uri(texture_uri.to_string()));
209        let _ = world.add_child(r_id, tex_id);
210        if let Some(filtering) = filtering {
211            let f_id = world.add_component(TextureFilteringComponent::new(filtering));
212            let _ = world.add_child(r_id, f_id);
213        }
214        if let Some(enabled) = emissive {
215            let e_id = world.add_component(EmissiveComponent::new(if enabled { 1.0 } else { 0.0 }));
216            let _ = world.add_child(r_id, e_id);
217        }
218        (r_id, uv_id, tex_id)
219    }
220    fn walk_text_layout<F>(
221        text: &str,
222        wrap_at: usize,
223        word_wrap: bool,
224        word_wrap_tokens: &[String],
225        font_size: f32,
226        mut callback: F,
227    ) -> WordWrapState
228    where
229        F: FnMut(TextLayoutEvent) -> bool,
230    {
231        let chars: Vec<char> = text.chars().collect();
232        let wrap_allowed_after = compute_wrap_allowed_after(&chars, word_wrap_tokens);
233        let word_run_len = compute_word_run_len(&wrap_allowed_after);
234        let mut state = WordWrapState::new(wrap_at, word_wrap, font_size);
235        for (i, ch) in chars.iter().copied().enumerate() {
236            if ch == '\n' {
237                if !callback(TextLayoutEvent::BeforeChar {
238                    index: i,
239                    ch,
240                    state,
241                }) {
242                    break;
243                }
244                state.newline();
245                continue;
246            }
247            state.apply_wrap_if_needed();
248            if ch == ' ' {
249                if !callback(TextLayoutEvent::BeforeChar {
250                    index: i,
251                    ch,
252                    state,
253                }) {
254                    break;
255                }
256                state.advance_space(i, &wrap_allowed_after);
257                continue;
258            }
259            if ch == '\t' {
260                if !callback(TextLayoutEvent::BeforeChar {
261                    index: i,
262                    ch,
263                    state,
264                }) {
265                    break;
266                }
267                state.advance_tab(i, &wrap_allowed_after);
268                continue;
269            }
270            state.apply_word_wrap_lookahead(word_run_len.get(i).copied().unwrap_or(1));
271            if !callback(TextLayoutEvent::BeforeChar {
272                index: i,
273                ch,
274                state,
275            }) {
276                break;
277            }
278            state.advance_glyph(i, &wrap_allowed_after);
279        }
280        state.max_col = state.max_col.max(state.col);
281        state
282    }
283    pub fn register_text(
284        &mut self,
285        world: &mut World,
286        _visuals: &mut VisualWorld,
287        component: ComponentId,
288    ) -> Vec<SpawnedGlyph> {
289        let Some(text_comp) = world.get_component_by_id_as::<TextComponent>(component) else {
290            return Vec::new();
291        };
292        if text_comp.is_built() {
293            return Vec::new();
294        }
295        let text = text_comp.text.clone();
296        let wrap_at = text_comp.wrap_at;
297        let font_size = text_comp.font_size;
298        let word_wrap = text_comp.word_wrap;
299        let word_wrap_tokens = text_comp.word_wrap_tokens.clone();
300        // Allow overriding the font atlas by attaching an immediate TextureComponent to the
301        // TextComponent root.
302        let inherited_font_texture_uri = world
303            .children_of(component)
304            .iter()
305            .find_map(|&ch| {
306                world
307                    .get_component_by_id_as::<TextureComponent>(ch)
308                    .and_then(|t| t.uri().map(|s| s.to_string()))
309            })
310            .unwrap_or_else(|| "assets/textures/font_system.dds".to_string());
311        // If the TextComponent has an immediate TextureFilteringComponent child,
312        // propagate it to all glyph renderables we spawn.
313        let inherited_filtering = world.children_of(component).iter().find_map(|&ch| {
314            world
315                .get_component_by_id_as::<TextureFilteringComponent>(ch)
316                .map(|c| c.filtering)
317        });
318        // Also allow styling at the TextComponent root: immediate Emissive children.
319        // (Color is now inherited by renderables from ancestors; no per-glyph ColorComponent needed.)
320        let inherited_emissive = world.children_of(component).iter().find_map(|&ch| {
321            world
322                .get_component_by_id_as::<EmissiveComponent>(ch)
323                .map(|e| e.intensity > 0.0)
324        });
325        // Raycasting is explicit opt-in. For text, allow toggling at the TextComponent root by
326        // attaching an immediate RaycastableComponent child; this is propagated to all glyphs.
327        // The full component is copied so that PointerEvents (click_only, drag_only, etc.) is preserved.
328        let inherited_raycastable = world.children_of(component).iter().find_map(|&ch| {
329            world
330                .get_component_by_id_as::<RaycastableComponent>(ch)
331                .copied()
332                .filter(|r| r.enable)
333        });
334        // Optional per-glyph shadow pass.
335        // Requested topology: TextShadowComponent is parented to the TextComponent.
336        let shadow: Option<TextShadowComponent> =
337            world.children_of(component).iter().find_map(|&ch| {
338                world
339                    .get_component_by_id_as::<TextShadowComponent>(ch)
340                    .copied()
341            });
342        // Mark built immediately to avoid re-entrancy/double-build.
343        if let Some(text_comp) = world.get_component_by_id_as_mut::<TextComponent>(component) {
344            text_comp.mark_built();
345        }
346        let text_input_root = {
347            let mut cur = world.parent_of(component);
348            let mut found = None;
349            while let Some(node) = cur {
350                if world
351                    .get_component_by_id_as::<TextInputComponent>(node)
352                    .is_some()
353                {
354                    found = Some(node);
355                    break;
356                }
357                cur = world.parent_of(node);
358            }
359            found
360        };
361        let mut spawned = Vec::new();
362        let is_text_input = text_input_root.is_some();
363        let _ = Self::walk_text_layout(
364            &text,
365            wrap_at,
366            word_wrap,
367            &word_wrap_tokens,
368            font_size,
369            |event| {
370                let TextLayoutEvent::BeforeChar {
371                    index: i,
372                    ch,
373                    state,
374                } = event;
375                if ch == ' ' || ch == '\t' {
376                    if !is_text_input {
377                        return true;
378                    }
379                    let (x, y) = state.cursor_pos();
380                    let width = if ch == '\t' {
381                        font_size * WordWrapState::TAB_WIDTH as f32
382                    } else {
383                        font_size
384                    };
385                    let t = TransformComponent::new()
386                        .with_position(x, y, 0.0)
387                        .with_scale(width, font_size, 1.0);
388                    let t_id = world.add_component(t);
389                    let _ = world.add_child(component, t_id);
390                    let t_serialize = world.add_component(SerializeComponent::off());
391                    let _ = world.add_child(t_id, t_serialize);
392                    let color = world.add_component(ColorComponent {
393                        rgba: [0.0, 0.0, 0.0, 0.0],
394                    });
395                    let _ = world.add_child(t_id, color);
396                    let r_id = world.add_component(RenderableComponent::square());
397                    let _ = world.add_child(color, r_id);
398                    if let Some(rc) = inherited_raycastable {
399                        let rc_id = world.add_component(rc);
400                        let _ = world.add_child(r_id, rc_id);
401                    }
402                    let opacity = world.add_component(OpacityComponent::new().with_opacity(0.0));
403                    let _ = world.add_child(r_id, opacity);
404                    let hit = world.add_component(TextInputGlyphHitComponent {
405                        text_input_root: text_input_root.unwrap(),
406                        text_target: component,
407                        char_index: i,
408                    });
409                    let _ = world.add_child(r_id, hit);
410                    return true;
411                }
412                if ch == '\n' {
413                    return true;
414                }
415                let (x, y) = state.cursor_pos();
416                let t = TransformComponent::new()
417                    .with_position(x, y, 0.0)
418                    .with_scale(font_size, font_size, 1.0);
419                let t_id = world.add_component(t);
420                let _ = world.add_child(component, t_id);
421                let t_serialize = world.add_component(SerializeComponent::off());
422                let _ = world.add_child(t_id, t_serialize);
423                let glyph_uvs = uvs_for_glyph(ch);
424                let (r_id, uv_id, tex_id) = Self::spawn_glyph_quad(
425                    world,
426                    t_id,
427                    glyph_uvs.clone(),
428                    &inherited_font_texture_uri,
429                    inherited_filtering,
430                    inherited_emissive,
431                    inherited_raycastable,
432                    None,
433                );
434                if let Some(text_input_root) = text_input_root {
435                    let hit = world.add_component(TextInputGlyphHitComponent {
436                        text_input_root,
437                        text_target: component,
438                        char_index: i,
439                    });
440                    let _ = world.add_child(r_id, hit);
441                }
442                if let Some(shadow) = shadow {
443                    let z_back = -shadow.offset[2].abs();
444                    let mut spawn_shadow = |scale: f32, z: f32| {
445                        let ot = TransformComponent::new()
446                            .with_position(shadow.offset[0], shadow.offset[1], z)
447                            .with_scale(scale, scale, 1.0);
448                        let ot_id = world.add_component(ot);
449                        let _ = world.add_child(t_id, ot_id);
450                        let ot_serialize = world.add_component(SerializeComponent::off());
451                        let _ = world.add_child(ot_id, ot_serialize);
452                        // Shadow quad: no raycasting by default.
453                        let _ = Self::spawn_glyph_quad(
454                            world,
455                            ot_id,
456                            glyph_uvs.clone(),
457                            &inherited_font_texture_uri,
458                            inherited_filtering,
459                            inherited_emissive,
460                            None,
461                            Some(shadow.rgba),
462                        );
463                    };
464                    // If the shadow is expanded (>1.0), spawn two shadow glyphs.
465                    if shadow.scale > 1.0 {
466                        spawn_shadow(1.0 / (shadow.scale * 1.3), z_back);
467                        spawn_shadow(shadow.scale, z_back * 2.0);
468                    } else {
469                        spawn_shadow(shadow.scale, z_back);
470                    }
471                }
472                spawned.push(SpawnedGlyph {
473                    transform: t_id,
474                    renderable: r_id,
475                    uv: uv_id,
476                    texture: tex_id,
477                });
478                true
479            },
480        );
481        spawned
482    }
483    /// Pure text measurement — runs wrap logic without spawning any glyphs.
484    ///
485    /// Returns `(width_gu, height_gu)` in glyph units after applying font size.
486    ///
487    /// `wrap_at = 0` disables wrapping.
488    pub fn measure(
489        text: &str,
490        wrap_at: usize,
491        word_wrap: bool,
492        word_wrap_tokens: &[String],
493        font_size: f32,
494    ) -> (f32, f32) {
495        let state = Self::walk_text_layout(
496            text,
497            wrap_at,
498            word_wrap,
499            word_wrap_tokens,
500            font_size,
501            |_| true,
502        );
503        (
504            state.max_col as f32 * font_size,
505            (state.row + 1) as f32 * font_size,
506        )
507    }
508    pub fn layout_position_for_index(
509        text: &str,
510        index: usize,
511        wrap_at: usize,
512        word_wrap: bool,
513        word_wrap_tokens: &[String],
514        font_size: f32,
515    ) -> (f32, f32) {
516        let mut result = None;
517        let state = Self::walk_text_layout(
518            text,
519            wrap_at,
520            word_wrap,
521            word_wrap_tokens,
522            font_size,
523            |event| match event {
524                TextLayoutEvent::BeforeChar {
525                    index: i, state, ..
526                } if i == index => {
527                    result = Some(state.cursor_pos());
528                    false
529                }
530                _ => true,
531            },
532        );
533        result.unwrap_or_else(|| state.cursor_pos())
534    }
535    pub fn caret_local_position(
536        text: &str,
537        caret: usize,
538        wrap_at: usize,
539        word_wrap: bool,
540        word_wrap_tokens: &[String],
541        font_size: f32,
542    ) -> (f32, f32) {
543        Self::layout_position_for_index(
544            text,
545            caret,
546            wrap_at,
547            word_wrap,
548            word_wrap_tokens,
549            font_size,
550        )
551    }
552}
553fn compute_wrap_allowed_after(chars: &[char], tokens: &[String]) -> Vec<bool> {
554    let mut wrap_allowed_after: Vec<bool> = vec![false; chars.len()];
555    // Always treat space/tab as wrap opportunities.
556    for (i, &ch) in chars.iter().enumerate() {
557        if ch == ' ' || ch == '\t' {
558            wrap_allowed_after[i] = true;
559        }
560    }
561    for tok in tokens {
562        if tok.is_empty() {
563            continue;
564        }
565        // Skip whitespace here; already handled above.
566        if tok == " " || tok == "\t" {
567            continue;
568        }
569        let tok_chars: Vec<char> = tok.chars().collect();
570        if tok_chars.is_empty() {
571            continue;
572        }
573        if tok_chars.len() > chars.len() {
574            continue;
575        }
576        for start in 0..=(chars.len() - tok_chars.len()) {
577            let mut matched = true;
578            for (j, &tch) in tok_chars.iter().enumerate() {
579                if chars[start + j] != tch {
580                    matched = false;
581                    break;
582                }
583            }
584            if matched {
585                let end = start + tok_chars.len() - 1;
586                wrap_allowed_after[end] = true;
587            }
588        }
589    }
590    wrap_allowed_after
591}
592impl crate::engine::ecs::system::System for TextSystem {
593    fn tick(
594        &mut self,
595        _world: &mut World,
596        _visuals: &mut VisualWorld,
597        _input: &crate::engine::user_input::InputState,
598        _dt_sec: f32,
599    ) {
600        // Text expansion currently happens via registration.
601    }
602}
603fn uvs_for_glyph(ch: char) -> Vec<[f32; 2]> {
604    const COLS: f32 = 16.0;
605    const ROWS: f32 = 16.0;
606    // Atlas layout is ASCII-order in a 16x16 grid:
607    // row = ascii_code / 16, col = ascii_code % 16
608    // e.g. '!': 33 => row 2, col 1 (with the two initial blank/control rows).
609    let code: u8 = if ch.is_ascii() {
610        ch as u8
611    } else {
612        b'?' // fallback
613    };
614    let row = (code / 16) as f32;
615    let col = (code % 16) as f32;
616    let u0 = col / COLS;
617    let u1 = (col + 1.0) / COLS;
618    // Atlas convention for `assets/textures/font_system.dds` (and `font.dds`):
619    // - Row 0 is the TOP row of the image.
620    // - Our texture sampling treats v=0 as TOP and v=1 as BOTTOM.
621    // - Each glyph is centered within its 1/16 × 1/16 cell (no baseline offset);
622    //   layout therefore treats the quad as 1×1 with the letter centered at the quad's center.
623    let v0 = row / ROWS;
624    let v1 = (row + 1.0) / ROWS;
625    // Quad vertex order from MeshFactory::quad_2d():
626    // 0 bottom-left, 1 bottom-right, 2 top-right, 3 top-left
627    // Since row 0 is the *top* of the atlas, bottom vertices must use v1.
628    vec![[u0, v1], [u1, v1], [u1, v0], [u0, v0]]
629}
630#[cfg(test)]
631mod tests {
632    use super::TextSystem;
633    use crate::engine::ecs::World;
634    use crate::engine::ecs::component::{
635        SerializeComponent, TextComponent, TextInputGlyphHitComponent, TextureComponent,
636        TransformComponent,
637    };
638    use crate::engine::graphics::VisualWorld;
639    fn collect_descendants(
640        world: &World,
641        root: crate::engine::ecs::ComponentId,
642    ) -> Vec<crate::engine::ecs::ComponentId> {
643        let mut stack = vec![root];
644        let mut out = Vec::new();
645        while let Some(node) = stack.pop() {
646            for child in world.children_of(node).iter().copied() {
647                out.push(child);
648                stack.push(child);
649            }
650        }
651        out
652    }
653    fn register_text_scales_spawned_glyphs_by_font_size() {
654        let mut world = World::default();
655        let mut visuals = VisualWorld::new();
656        let text_id = world.add_component(TextComponent::new("A").with_font_size(0.25));
657        let mut text_system = TextSystem::default();
658        let spawned = text_system.register_text(&mut world, &mut visuals, text_id);
659        assert_eq!(spawned.len(), 1);
660        let glyph_t = world
661            .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(
662                spawned[0].transform,
663            )
664            .expect("glyph transform");
665        assert!((glyph_t.transform.scale[0] - 0.25).abs() < 1e-6);
666        assert!((glyph_t.transform.scale[1] - 0.25).abs() < 1e-6);
667    }
668    fn register_text_marks_spawned_glyph_roots_serialize_off() {
669        let mut world = World::default();
670        let mut visuals = VisualWorld::new();
671        let text_id = world.add_component(TextComponent::new("A"));
672        let mut text_system = TextSystem::default();
673        let spawned = text_system.register_text(&mut world, &mut visuals, text_id);
674        assert_eq!(spawned.len(), 1);
675        let serialize_marker = world
676            .children_of(spawned[0].transform)
677            .iter()
678            .find(|&&child| {
679                world
680                    .get_component_by_id_as::<SerializeComponent>(child)
681                    .is_some()
682            })
683            .copied()
684            .expect("expected serialize marker on glyph root");
685        assert!(
686            world
687                .get_component_by_id_as::<SerializeComponent>(serialize_marker)
688                .is_some_and(|serialize| !serialize.enabled)
689        );
690    }
691    fn measure_font_size_scales_text_advance_and_height() {
692        let (w_small, h_small) = TextSystem::measure("AB", 0, true, &[], 0.5);
693        let (w_large, h_large) = TextSystem::measure("AB", 0, true, &[], 2.0);
694        assert!((w_small - 1.0).abs() < 1e-6);
695        assert!((w_large - 4.0).abs() < 1e-6);
696        assert!((h_small - 0.5).abs() < 1e-6);
697        assert!((h_large - 2.0).abs() < 1e-6);
698    }
699    fn layout_position_for_index_matches_caret_local_position() {
700        let text = "hello world";
701        let pos = TextSystem::layout_position_for_index(text, 4, 6, true, &[], 1.0);
702        let caret = TextSystem::caret_local_position(text, 4, 6, true, &[], 1.0);
703        assert_eq!(pos, caret);
704    }
705    fn layout_position_for_index_wraps_to_second_line_after_wrapped_word() {
706        let text = "hello world";
707        let pos = TextSystem::layout_position_for_index(text, 7, 6, true, &[], 1.0);
708        assert_eq!(pos, (1.5, -1.5));
709    }
710    fn layout_position_for_index_wraps_to_second_line_before_wrapped_word() {
711        let text = "hello world";
712        let pos = TextSystem::layout_position_for_index(text, 6, 6, true, &[], 1.0);
713        assert_eq!(pos, (0.5, -1.5));
714    }
715    fn register_text_spawns_text_input_whitespace_helpers() {
716        let mut world = World::default();
717        let mut visuals = VisualWorld::new();
718        let root = world.add_component(TransformComponent::new());
719        let text_input = world.add_component(
720            crate::engine::ecs::component::TextInputComponent::new("a b"),
721        );
722        let text = world.add_component(TextComponent::new("a b"));
723        let _ = world.add_child(root, text_input);
724        let _ = world.add_child(text_input, text);
725        let rc =
726            world.add_component(crate::engine::ecs::component::RaycastableComponent::enabled());
727        let _ = world.add_child(text, rc);
728        let mut text_system = TextSystem::default();
729        let spawned = text_system.register_text(&mut world, &mut visuals, text);
730        assert_eq!(spawned.len(), 2);
731        let mut whitespace_hit_count = 0;
732        for descendant in collect_descendants(&world, text) {
733            if world
734                .get_component_by_id_as::<TextInputGlyphHitComponent>(descendant)
735                .is_some()
736            {
737                let has_texture = world.children_of(descendant).iter().copied().any(|grand| {
738                    world
739                        .get_component_by_id_as::<TextureComponent>(grand)
740                        .is_some()
741                });
742                if !has_texture {
743                    whitespace_hit_count += 1;
744                }
745            }
746        }
747        assert!(
748            whitespace_hit_count >= 1,
749            "expected at least one whitespace helper hit quad"
750        );
751    }
752    fn register_text_does_not_spawn_whitespace_helpers_for_plain_text() {
753        let mut world = World::default();
754        let mut visuals = VisualWorld::new();
755        let root = world.add_component(TransformComponent::new());
756        let text = world.add_component(TextComponent::new("a b"));
757        let _ = world.add_child(root, text);
758        let rc =
759            world.add_component(crate::engine::ecs::component::RaycastableComponent::enabled());
760        let _ = world.add_child(text, rc);
761        let mut text_system = TextSystem::default();
762        let _ = text_system.register_text(&mut world, &mut visuals, text);
763        let mut whitespace_hit_found = false;
764        for descendant in collect_descendants(&world, text) {
765            if world
766                .get_component_by_id_as::<TextInputGlyphHitComponent>(descendant)
767                .is_some()
768            {
769                let has_texture = world.children_of(descendant).iter().copied().any(|grand| {
770                    world
771                        .get_component_by_id_as::<TextureComponent>(grand)
772                        .is_some()
773                });
774                if !has_texture {
775                    whitespace_hit_found = true;
776                }
777            }
778        }
779        assert!(
780            !whitespace_hit_found,
781            "plain text should not spawn whitespace hit helpers"
782        );
783    }
784}