//! `llimphi-widget-text-area` — input de texto multilínea para Llimphi. //! //! Versión multilínea del [`llimphi-widget-text-input`]. Mismo contrato Elm //! (estado en el `Model`, `apply_key` desde el `update`, view con foco), //! pero acepta `\n` como contenido válido: Enter inserta salto de línea //! en lugar de "submit". El llamador decide cómo commitear (típicamente //! Ctrl+Enter o un botón ✓ aparte). //! //! El render aprovecha que `View::text_aligned` ya hace layout multilínea //! vía parley (line wrap por `max_width`, saltos `\n` respetados). //! //! Limitaciones del PMV (heredadas del text-input): sin posicionamiento //! del cursor con flechas, sin selección, sin copy/paste, sin IME. El //! caret se simula como un bloque sólido al final del texto. #![forbid(unsafe_code)] use llimphi_ui::llimphi_layout::taffy::{ prelude::{length, percent, Size, Style}, Rect, }; use llimphi_ui::llimphi_raster::peniko::Color; use llimphi_ui::llimphi_text::Alignment; use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; /// Paleta del text-area — mismos slots que el text-input. #[derive(Debug, Clone, Copy)] pub struct TextAreaPalette { pub bg: Color, pub bg_focus: Color, pub border: Color, pub border_focus: Color, pub fg_text: Color, pub fg_placeholder: Color, } impl Default for TextAreaPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } impl TextAreaPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { bg: t.bg_input, bg_focus: t.bg_input_focus, border: t.border, border_focus: t.border_focus, fg_text: t.fg_text, fg_placeholder: t.fg_placeholder, } } } /// Estado del text-area. Vive en el `Model`; `apply_key` se llama desde /// el `update` para ediciones por tecla. #[derive(Debug, Clone, Default)] pub struct TextAreaState { text: String, } impl TextAreaState { pub fn new() -> Self { Self::default() } pub fn text(&self) -> &str { &self.text } pub fn is_empty(&self) -> bool { self.text.is_empty() } pub fn clear(&mut self) { self.text.clear(); } pub fn set_text(&mut self, s: impl Into) { self.text = s.into(); } /// Cantidad de líneas (≥ 1, mismo criterio que `str::lines` + 1 si /// el texto termina en `\n`). pub fn line_count(&self) -> usize { if self.text.is_empty() { return 1; } let mut n = self.text.lines().count(); if self.text.ends_with('\n') { n += 1; } n.max(1) } /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido. /// /// Maneja: Backspace, Enter (inserta `\n`), e inserción de /// caracteres imprimibles vía `event.text`. NO maneja: Tab (lo /// dejamos al caller — típicamente cambio de foco o indent), /// Escape, flechas. pub fn apply_key(&mut self, event: &KeyEvent) -> bool { if event.state != KeyState::Pressed { return false; } match &event.key { Key::Named(NamedKey::Backspace) => self.text.pop().is_some(), Key::Named(NamedKey::Enter) => { self.text.push('\n'); true } _ => { let Some(text) = event.text.as_ref() else { return false; }; // Filtramos caracteres de control — el `\n` lo metemos // sólo desde NamedKey::Enter para tener un único path. if text.is_empty() || text.chars().any(|c| c.is_control()) { return false; } self.text.push_str(text); true } } } } /// Render del text-area. `body_height` es el alto disponible del bloque /// (el widget no calcula altura automática; el caller decide). Con foco /// se pinta un caret bloque al final del texto. pub fn text_area_view( state: &TextAreaState, placeholder: &str, focused: bool, body_height: f32, palette: &TextAreaPalette, on_focus: Msg, ) -> View { let is_empty = state.is_empty(); let display = if is_empty { placeholder.to_string() } else { state.text.clone() }; let text_color = if is_empty { palette.fg_placeholder } else { palette.fg_text }; let (bg, border) = if focused { (palette.bg_focus, palette.border_focus) } else { (palette.bg, palette.border) }; let inner = View::new(Style { size: Size { width: percent(1.0_f32), height: length(body_height), }, padding: Rect { left: length(6.0_f32), right: length(6.0_f32), top: length(4.0_f32), bottom: length(4.0_f32), }, ..Default::default() }) .fill(bg) .text_aligned(display, 12.0, text_color, Alignment::Start); // Wrapper que pinta el borde como fill del padre (1 px alrededor // del inner gracias al padding del padre). View::new(Style { size: Size { width: percent(1.0_f32), height: length(body_height + 2.0), }, padding: Rect { left: length(1.0_f32), right: length(1.0_f32), top: length(1.0_f32), bottom: length(1.0_f32), }, ..Default::default() }) .fill(border) .on_click(on_focus) .children(vec![inner]) } #[cfg(test)] mod tests { use super::*; use llimphi_ui::Modifiers; fn k(named: NamedKey) -> KeyEvent { KeyEvent { key: Key::Named(named), state: KeyState::Pressed, text: None, modifiers: Modifiers::default(), repeat: false, } } fn k_text(s: &str) -> KeyEvent { KeyEvent { key: Key::Character(s.into()), state: KeyState::Pressed, text: Some(s.to_owned()), modifiers: Modifiers::default(), repeat: false, } } #[test] fn enter_inserta_salto_de_linea() { let mut s = TextAreaState::new(); s.apply_key(&k_text("a")); s.apply_key(&k(NamedKey::Enter)); s.apply_key(&k_text("b")); assert_eq!(s.text(), "a\nb"); assert_eq!(s.line_count(), 2); } #[test] fn backspace_borra_el_salto_y_une_lineas() { let mut s = TextAreaState::new(); s.set_text("a\nb"); s.apply_key(&k(NamedKey::Backspace)); s.apply_key(&k(NamedKey::Backspace)); assert_eq!(s.text(), "a"); } #[test] fn line_count_vacio_es_uno() { let s = TextAreaState::new(); assert_eq!(s.line_count(), 1); } #[test] fn line_count_cuenta_trailing_newline() { let mut s = TextAreaState::new(); s.set_text("a\nb\n"); assert_eq!(s.line_count(), 3); } #[test] fn caracteres_de_control_se_filtran() { let mut s = TextAreaState::new(); s.apply_key(&k_text("\t")); assert!(s.is_empty()); } #[test] fn set_text_roundtrip() { let mut s = TextAreaState::new(); s.set_text("hola\nmundo"); assert_eq!(s.text(), "hola\nmundo"); s.clear(); assert!(s.is_empty()); } }