Skip to main content

mittens_engine/engine/ecs/system/
text_input_system.rs

1use crate::engine::ecs::component::{
2    ColorComponent, OpacityComponent, RaycastableComponent, RenderableComponent,
3    SerializeComponent, TextComponent, TextInputComponent, TextInputGlyphHitComponent,
4    TransformComponent,
5};
6use crate::engine::ecs::rx::TextInputCaretDirection;
7use crate::engine::ecs::system::TextSystem;
8use crate::engine::ecs::{
9    ComponentId, EventSignal, IntentValue, RxWorld, Signal, SignalEmitter, SignalKind, World,
10};
11use crate::engine::user_input::{InputState, TextInputFrameEvent};
12
13#[derive(Debug, Default)]
14pub struct TextInputSystem {
15    focused: Option<ComponentId>,
16    handlers_installed: bool,
17}
18
19const OWNED_TEXT_INPUT_CONTENT_LABEL: &str = "__text_input_content";
20const OWNED_TEXT_INPUT_TEXT_LABEL: &str = "__text_input_text";
21const OWNED_TEXT_INPUT_CARET_BG_LABEL: &str = "__text_input_caret_bg";
22const OWNED_TEXT_INPUT_CARET_BG_OPACITY_LABEL: &str = "__text_input_caret_bg_opacity";
23const CARET_BG_RGBA: [f32; 4] = [1.0, 0.86, 0.24, 1.0];
24const CARET_BG_OPACITY_FOCUSED: f32 = 0.8;
25const CARET_BG_OPACITY_HIDDEN: f32 = 0.0;
26const CARET_BG_Z: f32 = -0.01;
27
28impl TextInputSystem {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    pub fn install_handlers(&mut self, rx: &mut RxWorld) {
34        if self.handlers_installed {
35            return;
36        }
37        self.handlers_installed = true;
38
39        rx.add_global_handler_closure(SignalKind::Click, move |world, emit, env| {
40            let Some(EventSignal::Click { renderable, .. }) = env.event.as_ref() else {
41                return;
42            };
43
44            // 1. Try glyph hit metadata first for caret placement.
45            let glyph_hit = world
46                .children_of(*renderable)
47                .iter()
48                .copied()
49                .find_map(|child| {
50                    world
51                        .get_component_by_id_as::<TextInputGlyphHitComponent>(child)
52                        .copied()
53                });
54
55            if let Some(hit) = glyph_hit {
56                emit.push_intent_now(
57                    hit.text_input_root,
58                    IntentValue::TextInputSetFocus {
59                        component_id: hit.text_input_root,
60                    },
61                );
62                emit.push_intent_now(
63                    hit.text_input_root,
64                    IntentValue::TextInputMoveCaretTo {
65                        index: hit.char_index,
66                    },
67                );
68                return;
69            }
70
71            // 2. Fallback to general text input focus.
72            if let Some(component_id) = nearest_text_input_ancestor(world, *renderable) {
73                emit.push_intent_now(
74                    component_id,
75                    IntentValue::TextInputSetFocus { component_id },
76                );
77            } else {
78                emit.push_intent_now(env.scope, IntentValue::TextInputClearFocus);
79            }
80        });
81    }
82
83    pub fn register_text_input(
84        &mut self,
85        world: &mut World,
86        emit: &mut dyn SignalEmitter,
87        component: ComponentId,
88    ) {
89        let text = world
90            .get_component_by_id_as::<TextInputComponent>(component)
91            .map(|input| input.text.clone());
92        let Some(text) = text else {
93            return;
94        };
95
96        let target = ensure_text_target(world, emit, component)
97            .or_else(|| resolve_text_target(world, component));
98
99        let _ = ensure_caret_bg(world, emit, component, target);
100        sync_caret_bg(world, emit, component, target);
101
102        if let Some(target) = target {
103            emit.push_intent_now(
104                component,
105                IntentValue::SetText {
106                    component_ids: vec![target],
107                    text,
108                },
109            );
110        }
111    }
112
113    pub fn tick_with_queue(
114        &mut self,
115        world: &World,
116        input: &InputState,
117        emit: &mut dyn SignalEmitter,
118    ) {
119        let Some(focused) = self.focused else {
120            return;
121        };
122        if world.get_component_record(focused).is_none() {
123            self.focused = None;
124            return;
125        }
126
127        for event in input.text_input_events() {
128            match event {
129                TextInputFrameEvent::InsertText(text) if !text.is_empty() => {
130                    emit.push_intent_now(
131                        focused,
132                        IntentValue::TextInputInsertText { text: text.clone() },
133                    );
134                }
135                TextInputFrameEvent::Backspace => {
136                    emit.push_intent_now(focused, IntentValue::TextInputBackspace);
137                }
138                TextInputFrameEvent::DeleteForward => {
139                    emit.push_intent_now(focused, IntentValue::TextInputDeleteForward);
140                }
141                TextInputFrameEvent::MoveCaretLeft => {
142                    emit.push_intent_now(
143                        focused,
144                        IntentValue::TextInputMoveCaret {
145                            direction: TextInputCaretDirection::Left,
146                            amount: 1,
147                        },
148                    );
149                }
150                TextInputFrameEvent::MoveCaretRight => {
151                    emit.push_intent_now(
152                        focused,
153                        IntentValue::TextInputMoveCaret {
154                            direction: TextInputCaretDirection::Right,
155                            amount: 1,
156                        },
157                    );
158                }
159                TextInputFrameEvent::InsertText(_) => {}
160            }
161        }
162    }
163
164    pub fn clear_focus_if_removed(&mut self, removed_nodes: &[ComponentId]) {
165        if self
166            .focused
167            .is_some_and(|focused| removed_nodes.contains(&focused))
168        {
169            self.focused = None;
170        }
171    }
172
173    pub fn execute_intent(
174        &mut self,
175        world: &mut World,
176        emit: &mut dyn SignalEmitter,
177        env: &Signal,
178    ) {
179        let Some(intent) = env.intent.as_ref() else {
180            return;
181        };
182
183        match &intent.value {
184            IntentValue::TextInputSetFocus { component_id } => {
185                self.set_focus(world, emit, *component_id);
186            }
187            IntentValue::TextInputClearFocus => {
188                self.clear_focus(world, emit, env.scope);
189            }
190            IntentValue::TextInputInsertText { text } => {
191                self.apply_text_edit(world, emit, env.scope, |input| {
192                    if input.read_only || text.is_empty() {
193                        return false;
194                    }
195                    let byte = char_to_byte_index(&input.text, input.caret);
196                    input.text.insert_str(byte, text);
197                    input.caret += text.chars().count();
198                    true
199                });
200            }
201            IntentValue::TextInputBackspace => {
202                self.apply_text_edit(world, emit, env.scope, |input| {
203                    if input.read_only || input.caret == 0 {
204                        return false;
205                    }
206                    let end = char_to_byte_index(&input.text, input.caret);
207                    let start = char_to_byte_index(&input.text, input.caret - 1);
208                    input.text.replace_range(start..end, "");
209                    input.caret -= 1;
210                    true
211                });
212            }
213            IntentValue::TextInputDeleteForward => {
214                self.apply_text_edit(world, emit, env.scope, |input| {
215                    if input.read_only {
216                        return false;
217                    }
218                    let char_count = input.text.chars().count();
219                    if input.caret >= char_count {
220                        return false;
221                    }
222                    let start = char_to_byte_index(&input.text, input.caret);
223                    let end = char_to_byte_index(&input.text, input.caret + 1);
224                    input.text.replace_range(start..end, "");
225                    true
226                });
227            }
228            IntentValue::TextInputMoveCaret { direction, amount } => {
229                self.move_caret(world, emit, env.scope, *direction, *amount);
230            }
231            IntentValue::TextInputMoveCaretTo { index } => {
232                self.move_caret_to(world, emit, env.scope, *index);
233            }
234            _ => {}
235        }
236    }
237
238    fn set_focus(
239        &mut self,
240        world: &mut World,
241        emit: &mut dyn SignalEmitter,
242        component_id: ComponentId,
243    ) {
244        if world
245            .get_component_by_id_as::<TextInputComponent>(component_id)
246            .is_none()
247        {
248            return;
249        }
250
251        let old = self.focused;
252        if old == Some(component_id) {
253            if let Some(input) =
254                world.get_component_by_id_as_mut::<TextInputComponent>(component_id)
255            {
256                input.focused = true;
257            }
258            let target = ensure_text_target(world, emit, component_id)
259                .or_else(|| resolve_text_target(world, component_id));
260            let _ = ensure_caret_bg(world, emit, component_id, target);
261            sync_caret_bg(world, emit, component_id, target);
262            return;
263        }
264
265        if let Some(old_id) = old {
266            if let Some(old_input) = world.get_component_by_id_as_mut::<TextInputComponent>(old_id)
267            {
268                old_input.focused = false;
269            }
270            let old_target = resolve_text_target(world, old_id);
271            sync_caret_bg(world, emit, old_id, old_target);
272        }
273        if let Some(new_input) =
274            world.get_component_by_id_as_mut::<TextInputComponent>(component_id)
275        {
276            new_input.focused = true;
277        }
278        self.focused = Some(component_id);
279
280        let target = ensure_text_target(world, emit, component_id)
281            .or_else(|| resolve_text_target(world, component_id));
282        let _ = ensure_caret_bg(world, emit, component_id, target);
283        sync_caret_bg(world, emit, component_id, target);
284
285        emit.push_event(
286            component_id,
287            EventSignal::TextInputFocusChanged {
288                old,
289                new: Some(component_id),
290            },
291        );
292    }
293
294    fn clear_focus(&mut self, world: &mut World, emit: &mut dyn SignalEmitter, scope: ComponentId) {
295        let old = self.focused.take();
296        let Some(old_id) = old else {
297            return;
298        };
299        if let Some(old_input) = world.get_component_by_id_as_mut::<TextInputComponent>(old_id) {
300            old_input.focused = false;
301        }
302        let old_target = resolve_text_target(world, old_id);
303        sync_caret_bg(world, emit, old_id, old_target);
304        emit.push_event(
305            scope,
306            EventSignal::TextInputFocusChanged {
307                old: Some(old_id),
308                new: None,
309            },
310        );
311    }
312
313    fn move_caret(
314        &mut self,
315        world: &mut World,
316        emit: &mut dyn SignalEmitter,
317        scope: ComponentId,
318        direction: TextInputCaretDirection,
319        amount: usize,
320    ) {
321        let Some(focused) = self.focused.filter(|focused| *focused == scope) else {
322            return;
323        };
324        if let Some(input) = world.get_component_by_id_as_mut::<TextInputComponent>(focused) {
325            let char_count = input.text.chars().count();
326            match direction {
327                TextInputCaretDirection::Left => input.caret = input.caret.saturating_sub(amount),
328                TextInputCaretDirection::Right => {
329                    input.caret = (input.caret + amount).min(char_count)
330                }
331            }
332        }
333        let target = resolve_text_target(world, focused);
334        sync_caret_bg(world, emit, focused, target);
335    }
336
337    fn move_caret_to(
338        &mut self,
339        world: &mut World,
340        emit: &mut dyn SignalEmitter,
341        scope: ComponentId,
342        index: usize,
343    ) {
344        let Some(focused) = self.focused.filter(|focused| *focused == scope) else {
345            return;
346        };
347        if let Some(input) = world.get_component_by_id_as_mut::<TextInputComponent>(focused) {
348            let char_count = input.text.chars().count();
349            input.caret = index.min(char_count);
350        }
351        let target = resolve_text_target(world, focused);
352        sync_caret_bg(world, emit, focused, target);
353    }
354
355    fn apply_text_edit(
356        &mut self,
357        world: &mut World,
358        emit: &mut dyn SignalEmitter,
359        scope: ComponentId,
360        mut edit: impl FnMut(&mut TextInputComponent) -> bool,
361    ) {
362        let Some(focused) = self.focused.filter(|focused| *focused == scope) else {
363            return;
364        };
365
366        let (changed, text, caret) = {
367            let Some(input) = world.get_component_by_id_as_mut::<TextInputComponent>(focused)
368            else {
369                return;
370            };
371            let changed = edit(input);
372            input.clamp_caret();
373            (changed, input.text.clone(), input.caret)
374        };
375
376        if !changed {
377            return;
378        }
379
380        let target = resolve_text_target(world, focused);
381        if let Some(target) = target {
382            emit.push_intent_now(
383                focused,
384                IntentValue::SetText {
385                    component_ids: vec![target],
386                    text: text.clone(),
387                },
388            );
389        }
390        sync_caret_bg(world, emit, focused, target);
391        emit.push_event(
392            focused,
393            EventSignal::TextInputChanged {
394                component_id: focused,
395                text,
396                caret,
397            },
398        );
399    }
400}
401
402fn resolve_text_target(world: &World, root: ComponentId) -> Option<ComponentId> {
403    let mut stack = vec![root];
404    while let Some(node) = stack.pop() {
405        if node != root
406            && world
407                .get_component_by_id_as::<TextComponent>(node)
408                .is_some()
409        {
410            return Some(node);
411        }
412        for &child in world.children_of(node).iter().rev() {
413            stack.push(child);
414        }
415    }
416    None
417}
418
419fn sync_caret_bg(
420    world: &mut World,
421    emit: &mut dyn SignalEmitter,
422    root: ComponentId,
423    text_target: Option<ComponentId>,
424) {
425    let Some((caret_bg, opacity_id, x, y, font_size, visible)) =
426        caret_bg_sync_state(world, root, text_target)
427    else {
428        return;
429    };
430
431    if let Some(transform) = world.get_component_by_id_as_mut::<TransformComponent>(caret_bg) {
432        transform.set_position(emit, x, y, CARET_BG_Z);
433        transform.set_scale(emit, font_size, font_size, 1.0);
434    }
435
436    if let Some(opacity) = world.get_component_by_id_as_mut::<OpacityComponent>(opacity_id) {
437        opacity.opacity = if visible {
438            CARET_BG_OPACITY_FOCUSED
439        } else {
440            CARET_BG_OPACITY_HIDDEN
441        };
442        emit.push_intent_now(
443            opacity_id,
444            IntentValue::RegisterOpacity {
445                component_ids: vec![opacity_id],
446            },
447        );
448    }
449}
450
451fn caret_bg_sync_state(
452    world: &World,
453    root: ComponentId,
454    text_target: Option<ComponentId>,
455) -> Option<(ComponentId, ComponentId, f32, f32, f32, bool)> {
456    let input = world.get_component_by_id_as::<TextInputComponent>(root)?;
457    let text_target = text_target?;
458    let caret_bg = resolve_named_descendant(world, root, OWNED_TEXT_INPUT_CARET_BG_LABEL)?;
459    let opacity_id =
460        resolve_named_descendant(world, caret_bg, OWNED_TEXT_INPUT_CARET_BG_OPACITY_LABEL)?;
461    let text = world.get_component_by_id_as::<TextComponent>(text_target)?;
462    let (x, y) = TextSystem::caret_local_position(
463        &input.text,
464        input.caret,
465        text.wrap_at,
466        text.word_wrap,
467        &text.word_wrap_tokens,
468        text.font_size,
469    );
470    Some((caret_bg, opacity_id, x, y, text.font_size, input.focused))
471}
472
473fn resolve_named_descendant(world: &World, root: ComponentId, label: &str) -> Option<ComponentId> {
474    let mut stack = vec![root];
475    while let Some(node) = stack.pop() {
476        if node != root && world.component_label(node) == Some(label) {
477            return Some(node);
478        }
479        for &child in world.children_of(node).iter().rev() {
480            stack.push(child);
481        }
482    }
483    None
484}
485
486fn ensure_text_target(
487    world: &mut World,
488    emit: &mut dyn SignalEmitter,
489    root: ComponentId,
490) -> Option<ComponentId> {
491    if let Some(existing) = resolve_text_target(world, root) {
492        return Some(existing);
493    }
494
495    let content = world.add_component_boxed_named(
496        OWNED_TEXT_INPUT_CONTENT_LABEL,
497        Box::new(TransformComponent::new().with_position(0.0, 0.0, 0.2)),
498    );
499    let text = world.add_component_boxed_named(
500        OWNED_TEXT_INPUT_TEXT_LABEL,
501        Box::new(TextComponent::new("")),
502    );
503    let raycastable = world.add_component_boxed_named(
504        "__text_input_raycastable",
505        Box::new(RaycastableComponent::enabled()),
506    );
507
508    let _ = world.add_child(root, content);
509    let _ = world.add_child(content, text);
510    let _ = world.add_child(text, raycastable);
511    world.init_component_tree(content, emit);
512
513    Some(text)
514}
515
516fn ensure_caret_bg(
517    world: &mut World,
518    emit: &mut dyn SignalEmitter,
519    root: ComponentId,
520    text_target: Option<ComponentId>,
521) -> Option<ComponentId> {
522    if let Some(existing) = resolve_named_descendant(world, root, OWNED_TEXT_INPUT_CARET_BG_LABEL) {
523        return Some(existing);
524    }
525
526    let host = text_target
527        .and_then(|text_target| world.parent_of(text_target))
528        .unwrap_or(root);
529    let bg = world.add_component_boxed_named(
530        OWNED_TEXT_INPUT_CARET_BG_LABEL,
531        Box::new(
532            TransformComponent::new()
533                .with_position(0.5, -0.5, CARET_BG_Z)
534                .with_scale(1.0, 1.0, 1.0),
535        ),
536    );
537    let _ = world.add_child(host, bg);
538
539    let serialize = world.add_component(SerializeComponent::off());
540    let _ = world.add_child(bg, serialize);
541
542    let color = world.add_component(ColorComponent {
543        rgba: CARET_BG_RGBA,
544    });
545    let _ = world.add_child(bg, color);
546
547    let renderable = world.add_component(RenderableComponent::square());
548    let _ = world.add_child(color, renderable);
549
550    let opacity = world.add_component_boxed_named(
551        OWNED_TEXT_INPUT_CARET_BG_OPACITY_LABEL,
552        Box::new(OpacityComponent::new().with_opacity(CARET_BG_OPACITY_HIDDEN)),
553    );
554    let _ = world.add_child(renderable, opacity);
555
556    world.init_component_tree(bg, emit);
557    Some(bg)
558}
559
560fn nearest_text_input_ancestor(world: &World, start: ComponentId) -> Option<ComponentId> {
561    let mut cur = Some(start);
562    while let Some(node) = cur {
563        if world
564            .get_component_by_id_as::<TextInputComponent>(node)
565            .is_some()
566        {
567            return Some(node);
568        }
569        if let Some(child_text_input) = world.children_of(node).iter().copied().find(|&child| {
570            world
571                .get_component_by_id_as::<TextInputComponent>(child)
572                .is_some()
573        }) {
574            return Some(child_text_input);
575        }
576        cur = world.parent_of(node);
577    }
578    None
579}
580
581fn char_to_byte_index(text: &str, char_index: usize) -> usize {
582    text.char_indices()
583        .nth(char_index)
584        .map(|(idx, _)| idx)
585        .unwrap_or(text.len())
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::engine::ecs::CommandQueue;
592    use crate::engine::ecs::component::{OpacityComponent, TransformComponent};
593    use crate::engine::ecs::system::SystemWorld;
594    use crate::engine::graphics::{RenderAssets, VisualWorld};
595
596    #[test]
597    fn focused_text_input_mutates_backing_text() {
598        let mut world = World::default();
599        let mut visuals = VisualWorld::default();
600        let mut render_assets = RenderAssets::new();
601        let mut systems = SystemWorld::default();
602        let mut queue = CommandQueue::new();
603
604        let root = world.add_component(TransformComponent::new());
605        let input = world.add_component(TextInputComponent::new("hi"));
606
607        let _ = world.add_child(root, input);
608
609        world.init_component_tree(root, &mut queue);
610        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut queue);
611
612        queue.push_intent_now(
613            input,
614            IntentValue::TextInputSetFocus {
615                component_id: input,
616            },
617        );
618        queue.push_intent_now(
619            input,
620            IntentValue::TextInputInsertText {
621                text: "!".to_string(),
622            },
623        );
624        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut queue);
625
626        let input_state = world
627            .get_component_by_id_as::<TextInputComponent>(input)
628            .expect("text input component");
629        assert_eq!(input_state.text, "hi!");
630        assert_eq!(input_state.caret, 3);
631
632        let text_state = world
633            .get_component_by_id_as::<TextComponent>(
634                resolve_text_target(&world, input).expect("spawned backing text"),
635            )
636            .expect("text component");
637        assert_eq!(text_state.text, "hi!");
638
639        let text_target = resolve_text_target(&world, input).expect("spawned backing text");
640        let has_raycastable = world.children_of(text_target).iter().copied().any(|child| {
641            world
642                .get_component_by_id_as::<RaycastableComponent>(child)
643                .is_some_and(|raycastable| raycastable.enable)
644        });
645        assert!(
646            has_raycastable,
647            "text input backing text should be raycastable"
648        );
649
650        let caret_bg = resolve_named_descendant(&world, input, OWNED_TEXT_INPUT_CARET_BG_LABEL)
651            .expect("text input caret background");
652        let caret_bg_transform = world
653            .get_component_by_id_as::<TransformComponent>(caret_bg)
654            .expect("caret bg transform");
655        assert_eq!(
656            caret_bg_transform.transform.translation,
657            [3.5, -0.5, CARET_BG_Z]
658        );
659
660        let caret_bg_opacity =
661            resolve_named_descendant(&world, caret_bg, OWNED_TEXT_INPUT_CARET_BG_OPACITY_LABEL)
662                .expect("caret bg opacity");
663        let caret_bg_opacity = world
664            .get_component_by_id_as::<OpacityComponent>(caret_bg_opacity)
665            .expect("caret bg opacity component");
666        assert!((caret_bg_opacity.opacity - CARET_BG_OPACITY_FOCUSED).abs() < 1e-6);
667
668        queue.push_intent_now(input, IntentValue::TextInputClearFocus);
669        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut queue);
670
671        let caret_bg_opacity =
672            resolve_named_descendant(&world, caret_bg, OWNED_TEXT_INPUT_CARET_BG_OPACITY_LABEL)
673                .expect("caret bg opacity after clear");
674        let caret_bg_opacity = world
675            .get_component_by_id_as::<OpacityComponent>(caret_bg_opacity)
676            .expect("caret bg opacity component after clear");
677        assert!(caret_bg_opacity.opacity.abs() < 1e-6);
678    }
679
680    #[test]
681    fn text_input_glyph_click_moves_caret() {
682        let mut world = World::default();
683        let mut visuals = VisualWorld::default();
684        let mut render_assets = RenderAssets::new();
685        let mut systems = SystemWorld::default();
686        let mut queue = CommandQueue::new();
687
688        // 1. Setup TextInput with "hello"
689        let root = world.add_component(TransformComponent::new());
690        let input = world.add_component(TextInputComponent::new("hello"));
691        let _ = world.add_child(root, input);
692
693        world.init_component_tree(root, &mut queue);
694        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut queue);
695
696        // 2. Identify the 'e' glyph (index 1)
697        let text_target = resolve_text_target(&world, input).expect("backing text");
698        let mut e_glyph_renderable = None;
699        for &t_id in world.children_of(text_target) {
700            for &r_id in world.children_of(t_id) {
701                if world
702                    .get_component_by_id_as::<RenderableComponent>(r_id)
703                    .is_some()
704                {
705                    let hit = world.children_of(r_id).iter().copied().find_map(|child| {
706                        world.get_component_by_id_as::<TextInputGlyphHitComponent>(child)
707                    });
708                    if let Some(hit) = hit {
709                        if hit.char_index == 1 {
710                            e_glyph_renderable = Some(r_id);
711                            break;
712                        }
713                    }
714                }
715            }
716            if e_glyph_renderable.is_some() {
717                break;
718            }
719        }
720
721        let e_glyph = e_glyph_renderable.expect("found 'e' glyph at index 1");
722
723        // 3. Simulate click on 'e' glyph
724        // Handlers must be installed for the click to trigger intents.
725        systems.text_input.install_handlers(&mut systems.rx);
726
727        queue.push_event(
728            input,
729            EventSignal::Click {
730                raycaster: input, // doesn't matter for this test
731                renderable: e_glyph,
732                hit_point: [0.0, 0.0, 0.0],
733                screen_pos_px: Some((0.0, 0.0)),
734            },
735        );
736
737        // process_commands will run the global click handler, which pushes intents,
738        // then it will execute those intents.
739        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut queue);
740
741        // 4. Verify caret moved to 1
742        let input_state = world
743            .get_component_by_id_as::<TextInputComponent>(input)
744            .expect("text input component");
745        assert_eq!(
746            input_state.caret, 1,
747            "Caret should have moved to clicked glyph index 1"
748        );
749        assert!(
750            input_state.focused,
751            "TextInput should be focused after click"
752        );
753
754        // 5. Verify caret background updated position
755        let caret_bg = resolve_named_descendant(&world, input, OWNED_TEXT_INPUT_CARET_BG_LABEL)
756            .expect("text input caret background");
757        let caret_bg_transform = world
758            .get_component_by_id_as::<TransformComponent>(caret_bg)
759            .expect("caret bg transform");
760        // 'e' is at index 1. In monospace 1.0 font size, cursor for index 1 is at x=1.0.
761        // TextSystem::caret_local_position("hello", 1, ...) returns (1.0, 0.0) -> centered at (1.5, -0.5)
762        assert_eq!(
763            caret_bg_transform.transform.translation,
764            [1.5, -0.5, CARET_BG_Z]
765        );
766    }
767}