⎇ bb21c28eb158d264c7b03f48fba731a75c1648f0
crates/modules/nahual/widgets/text_input/src/lib.rs
History
Who touched what
raw
feat(mirada): mirada-greeter — greeter de login del escritorio carmen
1//! `nahual_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 div, prelude::*, px, Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent,
27 Render, SharedString, Task, Window,
28};
29
30use nahual_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 /// Si `true`, el contenido se dibuja como puntos (`•`) en vez del
50 /// texto real — para campos de contraseña.
51 mask: bool,
52 /// Toggle del caret: alterna cada [`CARET_BLINK_INTERVAL`]
53 /// entre `true` (visible) y `false` (oculto). El render lo
54 /// considera junto con focus para decidir si dibujar `|`.
55 caret_visible: bool,
56 /// Task del loop de blink. Se mantiene en self para que el
57 /// drop del widget cancele el loop (sino seguiría tickeando
58 /// y notificando contra un Entity ya muerto).
59 _blink_task: Task<()>,
60}
61
62impl EventEmitter<TextInputEvent> for TextInput {}
63
64impl Focusable for TextInput {
65 fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
66 self.focus_handle.clone()
67 }
68}
69
70impl TextInput {
71 pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
72 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
73 // Loop de blink: alterna `caret_visible` y notifica para
74 // re-render. Vive en `_blink_task` (drop = cancel).
75 let blink_task = cx.spawn(async move |this, cx| {
76 let timer = cx.background_executor().clone();
77 loop {
78 timer.timer(CARET_BLINK_INTERVAL).await;
79 let updated = this
80 .update(cx, |me, cx| {
81 me.caret_visible = !me.caret_visible;
82 cx.notify();
83 })
84 .is_ok();
85 if !updated {
86 // Entity drop → salimos del loop.
87 break;
88 }
89 }
90 });
91 Self {
92 text: initial.into(),
93 focus_handle: cx.focus_handle(),
94 placeholder: SharedString::from(""),
95 mask: false,
96 caret_visible: true,
97 _blink_task: blink_task,
98 }
99 }
100
101 /// Setea el placeholder mostrado cuando el campo está vacío.
102 #[allow(dead_code)]
103 pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
104 self.placeholder = placeholder.into();
105 self
106 }
107
108 /// Dibuja el contenido como puntos (`•`) — para campos de
109 /// contraseña. El texto real sigue accesible vía [`Self::text`].
110 pub fn with_mask(mut self) -> Self {
111 self.mask = true;
112 self
113 }
114
115 pub fn text(&self) -> &str {
116 &self.text
117 }
118
119 /// Reemplaza el contenido completo (e.g. al abrir un modal pre-cargado).
120 pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
121 self.text = text.into();
122 cx.notify();
123 }
124
125 /// Pide focus para que las próximas teclas vayan al input. Llamar
126 /// cuando montás el widget en un modal para que esté "activo".
127 pub fn request_focus(&self, window: &mut Window) {
128 window.focus(&self.focus_handle);
129 }
130
131 fn handle_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
132 let key = event.keystroke.key.as_str();
133 match key {
134 "enter" => {
135 cx.emit(TextInputEvent::Confirmed(self.text.clone()));
136 return;
137 }
138 "escape" => {
139 cx.emit(TextInputEvent::Cancelled);
140 return;
141 }
142 "backspace" => {
143 self.text.pop();
144 cx.notify();
145 return;
146 }
147 _ => {}
148 }
149 // Char "imprimible": tomamos `key_char` (que respeta el layout +
150 // modificadores) si está presente. `key_char` es el que el sistema
151 // dice "esto es lo que el usuario realmente escribió".
152 if let Some(ch) = event.keystroke.key_char.as_deref() {
153 // Solo apendeamos si NO contiene control chars (newline,
154 // backspace, etc — que llegarían como key_char en algunas
155 // plataformas).
156 if !ch.chars().any(|c| c.is_control()) {
157 self.text.push_str(ch);
158 cx.notify();
159 }
160 }
161 }
162}
163
164impl Render for TextInput {
165 fn render(&mut self, w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
166 let theme = Theme::global(cx).clone();
167 let is_empty = self.text.is_empty();
168 // Border-color depende del focus: focused → accent (señal
169 // clara de "vas a tipear acá"); blur → border (silencioso).
170 // Sin esto era imposible saber qué input estaba activo en
171 // un form con varios fields.
172 let is_focused = self.focus_handle.is_focused(w);
173 let border_color = if is_focused {
174 theme.accent_strong
175 } else {
176 theme.border
177 };
178 // Caret visible cuando: (1) input tiene focus AND (2) el
179 // toggle del blink loop está en `true`. El loop alterna
180 // cada 500ms — feel familiar a los inputs del SO.
181 let show_caret = is_focused && self.caret_visible;
182 let shown = display_text(&self.text, self.mask);
183 let display: SharedString = if is_empty {
184 self.placeholder.clone()
185 } else if show_caret {
186 SharedString::from(format!("{shown}|"))
187 } else {
188 SharedString::from(shown)
189 };
190 let text_color = if is_empty {
191 theme.fg_disabled
192 } else {
193 theme.fg_text
194 };
195
196 div()
197 .id("nahual-text-input")
198 .track_focus(&self.focus_handle)
199 .key_context("YahwehTextInput")
200 .on_key_down(cx.listener(Self::handle_key_down))
201 .px(px(10.0))
202 .py(px(6.0))
203 .min_w(px(200.0))
204 .bg(theme.bg_panel)
205 .border_1()
206 .border_color(border_color)
207 .rounded(px(4.0))
208 .text_size(px(13.0))
209 .text_color(text_color)
210 .child(display)
211 }
212}
213
214/// Texto a mostrar: el contenido tal cual, o un punto (`•`) por cada
215/// carácter si el campo está enmascarado.
216fn display_text(text: &str, mask: bool) -> String {
217 if mask {
218 "•".repeat(text.chars().count())
219 } else {
220 text.to_string()
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::display_text;
227
228 #[test]
229 fn plain_text_shown_verbatim() {
230 assert_eq!(display_text("hola", false), "hola");
231 }
232
233 #[test]
234 fn masked_text_is_dots_one_per_char() {
235 assert_eq!(display_text("hola", true), "••••");
236 // Un punto por carácter Unicode, no por byte.
237 assert_eq!(display_text("ñoño", true), "••••");
238 assert_eq!(display_text("", true), "");
239 }
240}