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

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-text-area"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-text-area — input de texto multilínea para Llimphi: estado plano (String con \\n), apply_key con Enter→\\n + Backspace + caracteres imprimibles, render multilínea con caret bloque al final del último renglón."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-text-area
> Textarea multi-line para [llimphi](../../README.md).
Sin highlight (eso es `text-editor`). Wrap configurable, char count, placeholder.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-text-area
> Multi-line textarea for [llimphi](../../README.md).
No highlight (that's `text-editor`). Configurable wrap, char count, placeholder.
+261
View File
@@ -0,0 +1,261 @@
//! `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<String>) {
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<Msg: Clone + 'static>(
state: &TextAreaState,
placeholder: &str,
focused: bool,
body_height: f32,
palette: &TextAreaPalette,
on_focus: Msg,
) -> View<Msg> {
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());
}
}