tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/ui_engine/widgets/text_input/src/lib.rs Rust · 205 siq'ikuna · 7.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed ea074d7d5 · Sergio ·

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}