⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/ui_engine/widgets/text_input/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed
1//! `yahweh_widget_text_input` — input de texto minimal.
2//!
3//! Diseñado para diálogos cortos (rename, prompts). NO es un editor — no
4//! soporta:
5//! - cursor positioning con flechas / mouse,
6//! - selección con shift / arrastre,
7//! - copy / cut / paste,
8//! - IME / multilínea.
9//!
10//! Soporta lo justo:
11//! - escribir caracteres (cualquier `key_char` printable los appendea al final),
12//! - `Backspace` quita el último char,
13//! - `Enter` emite [`TextInputEvent::Confirmed`] con el texto actual,
14//! - `Escape` emite [`TextInputEvent::Cancelled`].
15//!
16//! Cuando montes el widget, llamá `request_focus(window)` para que reciba
17//! teclas de inmediato. El padre se subscribe vía `cx.subscribe(&input,
18//! …)` para recibir Confirmed/Cancelled.
19//!
20//! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
21//! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
22
23use std::time::Duration;
24
25use gpui::{
26 Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
27 SharedString, Task, Window, div, prelude::*, px,
28};
29
30use yahweh_theme::Theme;
31
32/// Período de toggle del caret. 500ms es el estándar de los inputs
33/// del SO; ni rápido demasiado (distrae) ni lento (parece muerto).
34const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
35
36#[derive(Clone, Debug)]
37pub enum TextInputEvent {
38 /// El usuario apretó Enter. El payload es el texto actual.
39 Confirmed(String),
40 /// El usuario apretó Escape. El padre suele cerrar el modal.
41 Cancelled,
42}
43
44pub struct TextInput {
45 text: String,
46 focus_handle: FocusHandle,
47 /// Placeholder visible cuando `text` está vacío.
48 placeholder: SharedString,
49 /// Toggle del caret: alterna cada [`CARET_BLINK_INTERVAL`]
50 /// entre `true` (visible) y `false` (oculto). El render lo
51 /// considera junto con focus para decidir si dibujar `|`.
52 caret_visible: bool,
53 /// Task del loop de blink. Se mantiene en self para que el
54 /// drop del widget cancele el loop (sino seguiría tickeando
55 /// y notificando contra un Entity ya muerto).
56 _blink_task: Task<()>,
57}
58
59impl EventEmitter<TextInputEvent> for TextInput {}
60
61impl Focusable for TextInput {
62 fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
63 self.focus_handle.clone()
64 }
65}
66
67impl TextInput {
68 pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
69 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
70 // Loop de blink: alterna `caret_visible` y notifica para
71 // re-render. Vive en `_blink_task` (drop = cancel).
72 let blink_task = cx.spawn(async move |this, cx| {
73 let timer = cx.background_executor().clone();
74 loop {
75 timer.timer(CARET_BLINK_INTERVAL).await;
76 let updated = this
77 .update(cx, |me, cx| {
78 me.caret_visible = !me.caret_visible;
79 cx.notify();
80 })
81 .is_ok();
82 if !updated {
83 // Entity drop → salimos del loop.
84 break;
85 }
86 }
87 });
88 Self {
89 text: initial.into(),
90 focus_handle: cx.focus_handle(),
91 placeholder: SharedString::from(""),
92 caret_visible: true,
93 _blink_task: blink_task,
94 }
95 }
96
97 /// Setea el placeholder mostrado cuando el campo está vacío.
98 #[allow(dead_code)]
99 pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
100 self.placeholder = placeholder.into();
101 self
102 }
103
104 pub fn text(&self) -> &str {
105 &self.text
106 }
107
108 /// Reemplaza el contenido completo (e.g. al abrir un modal pre-cargado).
109 pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
110 self.text = text.into();
111 cx.notify();
112 }
113
114 /// Pide focus para que las próximas teclas vayan al input. Llamar
115 /// cuando montás el widget en un modal para que esté "activo".
116 pub fn request_focus(&self, window: &mut Window) {
117 window.focus(&self.focus_handle);
118 }
119
120 fn handle_key_down(
121 &mut self,
122 event: &KeyDownEvent,
123 _w: &mut Window,
124 cx: &mut Context<Self>,
125 ) {
126 let key = event.keystroke.key.as_str();
127 match key {
128 "enter" => {
129 cx.emit(TextInputEvent::Confirmed(self.text.clone()));
130 return;
131 }
132 "escape" => {
133 cx.emit(TextInputEvent::Cancelled);
134 return;
135 }
136 "backspace" => {
137 self.text.pop();
138 cx.notify();
139 return;
140 }
141 _ => {}
142 }
143 // Char "imprimible": tomamos `key_char` (que respeta el layout +
144 // modificadores) si está presente. `key_char` es el que el sistema
145 // dice "esto es lo que el usuario realmente escribió".
146 if let Some(ch) = event.keystroke.key_char.as_deref() {
147 // Solo apendeamos si NO contiene control chars (newline,
148 // backspace, etc — que llegarían como key_char en algunas
149 // plataformas).
150 if !ch.chars().any(|c| c.is_control()) {
151 self.text.push_str(ch);
152 cx.notify();
153 }
154 }
155 }
156}
157
158impl Render for TextInput {
159 fn render(&mut self, w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
160 let theme = Theme::global(cx).clone();
161 let is_empty = self.text.is_empty();
162 // Border-color depende del focus: focused → accent (señal
163 // clara de "vas a tipear acá"); blur → border (silencioso).
164 // Sin esto era imposible saber qué input estaba activo en
165 // un form con varios fields.
166 let is_focused = self.focus_handle.is_focused(w);
167 let border_color = if is_focused {
168 theme.accent_strong
169 } else {
170 theme.border
171 };
172 // Caret visible cuando: (1) input tiene focus AND (2) el
173 // toggle del blink loop está en `true`. El loop alterna
174 // cada 500ms — feel familiar a los inputs del SO.
175 let show_caret = is_focused && self.caret_visible;
176 let display: SharedString = if is_empty {
177 self.placeholder.clone()
178 } else if show_caret {
179 SharedString::from(format!("{}|", self.text))
180 } else {
181 SharedString::from(self.text.clone())
182 };
183 let text_color = if is_empty {
184 theme.fg_disabled
185 } else {
186 theme.fg_text
187 };
188
189 div()
190 .id("yahweh-text-input")
191 .track_focus(&self.focus_handle)
192 .key_context("YahwehTextInput")
193 .on_key_down(cx.listener(Self::handle_key_down))
194 .px(px(10.0))
195 .py(px(6.0))
196 .min_w(px(200.0))
197 .bg(theme.bg_panel.clone())
198 .border_1()
199 .border_color(border_color)
200 .rounded(px(4.0))
201 .text_size(px(13.0))
202 .text_color(text_color)
203 .child(display)
204 }
205}