//! `yahweh_widget_text_input` — input de texto minimal. //! //! Diseñado para diálogos cortos (rename, prompts). NO es un editor — no //! soporta: //! - cursor positioning con flechas / mouse, //! - selección con shift / arrastre, //! - copy / cut / paste, //! - IME / multilínea. //! //! Soporta lo justo: //! - escribir caracteres (cualquier `key_char` printable los appendea al final), //! - `Backspace` quita el último char, //! - `Enter` emite [`TextInputEvent::Confirmed`] con el texto actual, //! - `Escape` emite [`TextInputEvent::Cancelled`]. //! //! Cuando montes el widget, llamá `request_focus(window)` para que reciba //! teclas de inmediato. El padre se subscribe vía `cx.subscribe(&input, //! …)` para recibir Confirmed/Cancelled. //! //! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el //! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista. use std::time::Duration; use gpui::{ Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render, SharedString, Task, Window, div, prelude::*, px, }; use yahweh_theme::Theme; /// Período de toggle del caret. 500ms es el estándar de los inputs /// del SO; ni rápido demasiado (distrae) ni lento (parece muerto). const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); #[derive(Clone, Debug)] pub enum TextInputEvent { /// El usuario apretó Enter. El payload es el texto actual. Confirmed(String), /// El usuario apretó Escape. El padre suele cerrar el modal. Cancelled, } pub struct TextInput { text: String, focus_handle: FocusHandle, /// Placeholder visible cuando `text` está vacío. placeholder: SharedString, /// Toggle del caret: alterna cada [`CARET_BLINK_INTERVAL`] /// entre `true` (visible) y `false` (oculto). El render lo /// considera junto con focus para decidir si dibujar `|`. caret_visible: bool, /// Task del loop de blink. Se mantiene en self para que el /// drop del widget cancele el loop (sino seguiría tickeando /// y notificando contra un Entity ya muerto). _blink_task: Task<()>, } impl EventEmitter for TextInput {} impl Focusable for TextInput { fn focus_handle(&self, _: &gpui::App) -> FocusHandle { self.focus_handle.clone() } } impl TextInput { pub fn new(initial: impl Into, cx: &mut Context) -> Self { cx.observe_global::(|_, cx| cx.notify()).detach(); // Loop de blink: alterna `caret_visible` y notifica para // re-render. Vive en `_blink_task` (drop = cancel). let blink_task = cx.spawn(async move |this, cx| { let timer = cx.background_executor().clone(); loop { timer.timer(CARET_BLINK_INTERVAL).await; let updated = this .update(cx, |me, cx| { me.caret_visible = !me.caret_visible; cx.notify(); }) .is_ok(); if !updated { // Entity drop → salimos del loop. break; } } }); Self { text: initial.into(), focus_handle: cx.focus_handle(), placeholder: SharedString::from(""), caret_visible: true, _blink_task: blink_task, } } /// Setea el placeholder mostrado cuando el campo está vacío. #[allow(dead_code)] pub fn with_placeholder(mut self, placeholder: impl Into) -> Self { self.placeholder = placeholder.into(); self } pub fn text(&self) -> &str { &self.text } /// Reemplaza el contenido completo (e.g. al abrir un modal pre-cargado). pub fn set_text(&mut self, text: impl Into, cx: &mut Context) { self.text = text.into(); cx.notify(); } /// Pide focus para que las próximas teclas vayan al input. Llamar /// cuando montás el widget en un modal para que esté "activo". pub fn request_focus(&self, window: &mut Window) { window.focus(&self.focus_handle); } fn handle_key_down( &mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context, ) { let key = event.keystroke.key.as_str(); match key { "enter" => { cx.emit(TextInputEvent::Confirmed(self.text.clone())); return; } "escape" => { cx.emit(TextInputEvent::Cancelled); return; } "backspace" => { self.text.pop(); cx.notify(); return; } _ => {} } // Char "imprimible": tomamos `key_char` (que respeta el layout + // modificadores) si está presente. `key_char` es el que el sistema // dice "esto es lo que el usuario realmente escribió". if let Some(ch) = event.keystroke.key_char.as_deref() { // Solo apendeamos si NO contiene control chars (newline, // backspace, etc — que llegarían como key_char en algunas // plataformas). if !ch.chars().any(|c| c.is_control()) { self.text.push_str(ch); cx.notify(); } } } } impl Render for TextInput { fn render(&mut self, w: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::global(cx).clone(); let is_empty = self.text.is_empty(); // Border-color depende del focus: focused → accent (señal // clara de "vas a tipear acá"); blur → border (silencioso). // Sin esto era imposible saber qué input estaba activo en // un form con varios fields. let is_focused = self.focus_handle.is_focused(w); let border_color = if is_focused { theme.accent_strong } else { theme.border }; // Caret visible cuando: (1) input tiene focus AND (2) el // toggle del blink loop está en `true`. El loop alterna // cada 500ms — feel familiar a los inputs del SO. let show_caret = is_focused && self.caret_visible; let display: SharedString = if is_empty { self.placeholder.clone() } else if show_caret { SharedString::from(format!("{}|", self.text)) } else { SharedString::from(self.text.clone()) }; let text_color = if is_empty { theme.fg_disabled } else { theme.fg_text }; div() .id("yahweh-text-input") .track_focus(&self.focus_handle) .key_context("YahwehTextInput") .on_key_down(cx.listener(Self::handle_key_down)) .px(px(10.0)) .py(px(6.0)) .min_w(px(200.0)) .bg(theme.bg_panel.clone()) .border_1() .border_color(border_color) .rounded(px(4.0)) .text_size(px(13.0)) .text_color(text_color) .child(display) } }