tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ bb21c28eb158d264c7b03f48fba731a75c1648f0 crates/modules/nahual/widgets/text_input/src/lib.rs Rust · 240 lines · 8.1 KB History Who touched what raw

feat(mirada): mirada-greeter — greeter de login del escritorio carmen bb21c28eb · sergio ·

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}