tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `llimphi-widget-field` — wrapper de formulario para inputs.
2//!
3//! Patrón estándar de campo:
4//! ```text
5//! Nombre del campo (label — bold, fg_text)
6//! [ input control aquí ] (slot — viene como View<Msg>)
7//! Descripción o error abajo (helper — fg_muted o fg_destructive)
8//! ```
9//!
10//! El widget no implementa el input — lo recibe como `View<Msg>` y lo
11//! envuelve. Esto permite usarlo con `text-input`, `text-area`, `switch`,
12//! `segmented` o cualquier otro control.
13
14#![forbid(unsafe_code)]
15
16
17// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
18// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
19// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
20#![no_std]
21
22extern crate alloc;
23
24use alloc::string::String;
25use alloc::vec::Vec;
26use alloc::vec;
27use alloc::format;
28use llimphi_compositor::llimphi_layout::taffy::{
29 prelude::{length, percent, FlexDirection, Size, Style},
30};
31use llimphi_compositor::peniko::Color;
32use llimphi_compositor::llimphi_text::Alignment;
33use llimphi_compositor::View;
34use llimphi_theme::Theme;
35
36#[derive(Debug, Clone, Copy)]
37pub struct FieldPalette {
38 pub fg_label: Color,
39 pub fg_helper: Color,
40 pub fg_error: Color,
41 pub fg_required: Color,
42}
43
44impl FieldPalette {
45 pub fn from_theme(t: &Theme) -> Self {
46 Self {
47 fg_label: t.fg_text,
48 fg_helper: t.fg_muted,
49 fg_error: t.fg_destructive,
50 fg_required: t.fg_destructive,
51 }
52 }
53}
54
55/// Spec del field. `helper` y `error` son mutuamente excluyentes —
56/// si hay error, se renderiza el error (rojo); si no, el helper.
57pub struct FieldSpec<Msg: Clone + 'static> {
58 pub label: String,
59 /// El input/control concreto (text-input, switch, segmented, etc).
60 pub control: View<Msg>,
61 /// Marca el field como requerido — agrega un asterisco al label.
62 pub required: bool,
63 /// Texto explicativo debajo del control. `None` para omitirlo.
64 pub helper: Option<String>,
65 /// Mensaje de error — gana sobre `helper` cuando está presente.
66 pub error: Option<String>,
67 pub palette: FieldPalette,
68}
69
70const LABEL_H: f32 = 16.0;
71const HELPER_H: f32 = 16.0;
72const GAP_LABEL: f32 = 4.0;
73const GAP_HELPER: f32 = 4.0;
74const LABEL_FONT: f32 = 11.5;
75const HELPER_FONT: f32 = 10.5;
76
77pub fn field_view<Msg: Clone + 'static>(spec: FieldSpec<Msg>) -> View<Msg> {
78 let FieldSpec {
79 label,
80 control,
81 required,
82 helper,
83 error,
84 palette,
85 } = spec;
86
87 // Label: nombre + (asterisco si required).
88 // El nombre limpio (sin asterisco) es el que enuncia el lector: el `*` es
89 // notación visual, y `required` ya viaja como flag.
90 let aria_nombre = label.clone();
91 let label_text = if required { format!("{label} *") } else { label };
92 let label_view = View::new(Style {
93 size: Size {
94 width: percent(1.0_f32),
95 height: length(LABEL_H),
96 },
97 flex_shrink: 0.0,
98 ..Default::default()
99 })
100 .text_aligned(label_text, LABEL_FONT, palette.fg_label, Alignment::Start)
101 .role(llimphi_compositor::Role::Label);
102
103 // Helper / error — error gana si presente.
104 let helper_text = error.clone().or(helper.clone());
105 let helper_color = if error.is_some() { palette.fg_error } else { palette.fg_helper };
106
107 let mut children: Vec<View<Msg>> = vec![label_view, spacer(GAP_LABEL), control];
108 if let Some(t) = helper_text {
109 children.push(spacer(GAP_HELPER));
110 children.push(
111 View::new(Style {
112 size: Size {
113 width: percent(1.0_f32),
114 height: length(HELPER_H),
115 },
116 flex_shrink: 0.0,
117 ..Default::default()
118 })
119 .text_aligned(t, HELPER_FONT, helper_color, Alignment::Start),
120 );
121 }
122
123 // El grupo lleva el nombre y el estado del campo. Llimphi no modela el
124 // `aria-labelledby` que asociaría label↔control, así que la aproximación
125 // es agrupar: al entrar, el lector enuncia "<nombre>, requerido, <ayuda>"
126 // y después el control.
127 let mut raiz = View::new(Style {
128 flex_direction: FlexDirection::Column,
129 size: Size {
130 width: percent(1.0_f32),
131 height: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
132 },
133 ..Default::default()
134 })
135 .role(llimphi_compositor::Role::Group)
136 .aria_label(aria_nombre)
137 .children(children);
138 if required {
139 raiz = raiz.aria_required(true);
140 }
141 // El error se anuncia como descripción; el helper también, si no hay error.
142 if let Some(d) = error.or(helper) {
143 raiz = raiz.aria_description(d);
144 }
145 raiz
146}
147
148fn spacer<Msg: Clone + 'static>(h: f32) -> View<Msg> {
149 View::new(Style {
150 size: Size {
151 width: percent(1.0_f32),
152 height: length(h),
153 },
154 flex_shrink: 0.0,
155 ..Default::default()
156 })
157}