gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb widgets/text-area/src/lib.rs Rust · 261 líneas · 7.3 KB Historia Quién tocó qué crudo

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

1//! `llimphi-widget-text-area` — input de texto multilínea para Llimphi.
2//!
3//! Versión multilínea del [`llimphi-widget-text-input`]. Mismo contrato Elm
4//! (estado en el `Model`, `apply_key` desde el `update`, view con foco),
5//! pero acepta `\n` como contenido válido: Enter inserta salto de línea
6//! en lugar de "submit". El llamador decide cómo commitear (típicamente
7//! Ctrl+Enter o un botón ✓ aparte).
8//!
9//! El render aprovecha que `View::text_aligned` ya hace layout multilínea
10//! vía parley (line wrap por `max_width`, saltos `\n` respetados).
11//!
12//! Limitaciones del PMV (heredadas del text-input): sin posicionamiento
13//! del cursor con flechas, sin selección, sin copy/paste, sin IME. El
14//! caret se simula como un bloque sólido al final del texto.
15
16#![forbid(unsafe_code)]
17
18use llimphi_ui::llimphi_layout::taffy::{
19    prelude::{length, percent, Size, Style},
20    Rect,
21};
22use llimphi_ui::llimphi_raster::peniko::Color;
23use llimphi_ui::llimphi_text::Alignment;
24use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
25
26/// Paleta del text-area — mismos slots que el text-input.
27#[derive(Debug, Clone, Copy)]
28pub struct TextAreaPalette {
29    pub bg: Color,
30    pub bg_focus: Color,
31    pub border: Color,
32    pub border_focus: Color,
33    pub fg_text: Color,
34    pub fg_placeholder: Color,
35}
36
37impl Default for TextAreaPalette {
38    fn default() -> Self {
39        Self::from_theme(&llimphi_theme::Theme::dark())
40    }
41}
42
43impl TextAreaPalette {
44    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
45        Self {
46            bg: t.bg_input,
47            bg_focus: t.bg_input_focus,
48            border: t.border,
49            border_focus: t.border_focus,
50            fg_text: t.fg_text,
51            fg_placeholder: t.fg_placeholder,
52        }
53    }
54}
55
56/// Estado del text-area. Vive en el `Model`; `apply_key` se llama desde
57/// el `update` para ediciones por tecla.
58#[derive(Debug, Clone, Default)]
59pub struct TextAreaState {
60    text: String,
61}
62
63impl TextAreaState {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    pub fn text(&self) -> &str {
69        &self.text
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.text.is_empty()
74    }
75
76    pub fn clear(&mut self) {
77        self.text.clear();
78    }
79
80    pub fn set_text(&mut self, s: impl Into<String>) {
81        self.text = s.into();
82    }
83
84    /// Cantidad de líneas (≥ 1, mismo criterio que `str::lines` + 1 si
85    /// el texto termina en `\n`).
86    pub fn line_count(&self) -> usize {
87        if self.text.is_empty() {
88            return 1;
89        }
90        let mut n = self.text.lines().count();
91        if self.text.ends_with('\n') {
92            n += 1;
93        }
94        n.max(1)
95    }
96
97    /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido.
98    ///
99    /// Maneja: Backspace, Enter (inserta `\n`), e inserción de
100    /// caracteres imprimibles vía `event.text`. NO maneja: Tab (lo
101    /// dejamos al caller — típicamente cambio de foco o indent),
102    /// Escape, flechas.
103    pub fn apply_key(&mut self, event: &KeyEvent) -> bool {
104        if event.state != KeyState::Pressed {
105            return false;
106        }
107        match &event.key {
108            Key::Named(NamedKey::Backspace) => self.text.pop().is_some(),
109            Key::Named(NamedKey::Enter) => {
110                self.text.push('\n');
111                true
112            }
113            _ => {
114                let Some(text) = event.text.as_ref() else {
115                    return false;
116                };
117                // Filtramos caracteres de control — el `\n` lo metemos
118                // sólo desde NamedKey::Enter para tener un único path.
119                if text.is_empty() || text.chars().any(|c| c.is_control()) {
120                    return false;
121                }
122                self.text.push_str(text);
123                true
124            }
125        }
126    }
127}
128
129/// Render del text-area. `body_height` es el alto disponible del bloque
130/// (el widget no calcula altura automática; el caller decide). Con foco
131/// se pinta un caret bloque al final del texto.
132pub fn text_area_view<Msg: Clone + 'static>(
133    state: &TextAreaState,
134    placeholder: &str,
135    focused: bool,
136    body_height: f32,
137    palette: &TextAreaPalette,
138    on_focus: Msg,
139) -> View<Msg> {
140    let is_empty = state.is_empty();
141    let display = if is_empty {
142        placeholder.to_string()
143    } else {
144        state.text.clone()
145    };
146    let text_color = if is_empty { palette.fg_placeholder } else { palette.fg_text };
147    let (bg, border) = if focused {
148        (palette.bg_focus, palette.border_focus)
149    } else {
150        (palette.bg, palette.border)
151    };
152
153    let inner = View::new(Style {
154        size: Size {
155            width: percent(1.0_f32),
156            height: length(body_height),
157        },
158        padding: Rect {
159            left: length(6.0_f32),
160            right: length(6.0_f32),
161            top: length(4.0_f32),
162            bottom: length(4.0_f32),
163        },
164        ..Default::default()
165    })
166    .fill(bg)
167    .text_aligned(display, 12.0, text_color, Alignment::Start);
168
169    // Wrapper que pinta el borde como fill del padre (1 px alrededor
170    // del inner gracias al padding del padre).
171    View::new(Style {
172        size: Size {
173            width: percent(1.0_f32),
174            height: length(body_height + 2.0),
175        },
176        padding: Rect {
177            left: length(1.0_f32),
178            right: length(1.0_f32),
179            top: length(1.0_f32),
180            bottom: length(1.0_f32),
181        },
182        ..Default::default()
183    })
184    .fill(border)
185    .on_click(on_focus)
186    .children(vec![inner])
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use llimphi_ui::Modifiers;
193
194    fn k(named: NamedKey) -> KeyEvent {
195        KeyEvent {
196            key: Key::Named(named),
197            state: KeyState::Pressed,
198            text: None,
199            modifiers: Modifiers::default(),
200            repeat: false,
201        }
202    }
203
204    fn k_text(s: &str) -> KeyEvent {
205        KeyEvent {
206            key: Key::Character(s.into()),
207            state: KeyState::Pressed,
208            text: Some(s.to_owned()),
209            modifiers: Modifiers::default(),
210            repeat: false,
211        }
212    }
213
214    #[test]
215    fn enter_inserta_salto_de_linea() {
216        let mut s = TextAreaState::new();
217        s.apply_key(&k_text("a"));
218        s.apply_key(&k(NamedKey::Enter));
219        s.apply_key(&k_text("b"));
220        assert_eq!(s.text(), "a\nb");
221        assert_eq!(s.line_count(), 2);
222    }
223
224    #[test]
225    fn backspace_borra_el_salto_y_une_lineas() {
226        let mut s = TextAreaState::new();
227        s.set_text("a\nb");
228        s.apply_key(&k(NamedKey::Backspace));
229        s.apply_key(&k(NamedKey::Backspace));
230        assert_eq!(s.text(), "a");
231    }
232
233    #[test]
234    fn line_count_vacio_es_uno() {
235        let s = TextAreaState::new();
236        assert_eq!(s.line_count(), 1);
237    }
238
239    #[test]
240    fn line_count_cuenta_trailing_newline() {
241        let mut s = TextAreaState::new();
242        s.set_text("a\nb\n");
243        assert_eq!(s.line_count(), 3);
244    }
245
246    #[test]
247    fn caracteres_de_control_se_filtran() {
248        let mut s = TextAreaState::new();
249        s.apply_key(&k_text("\t"));
250        assert!(s.is_empty());
251    }
252
253    #[test]
254    fn set_text_roundtrip() {
255        let mut s = TextAreaState::new();
256        s.set_text("hola\nmundo");
257        assert_eq!(s.text(), "hola\nmundo");
258        s.clear();
259        assert!(s.is_empty());
260    }
261}