tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main widgets/field/src/lib.rs Rust · 157 lines · 4.9 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}