tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb
widgets/text-area/src/lib.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
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}