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-chip` — chip compacto con cuatro sabores: **filter**
2//! (toggle binario), **choice** (radio dentro de un grupo), **input**
3//! (etiqueta removible con × al final) y **assist** (chip-acción con
4//! ícono opcional).
5//!
6//! Forma: rectángulo redondeado bien pegado (radius pill), padding
7//! horizontal 10/4, alto 24 px. Tema-consciente: cuando `selected`,
8//! pinta `accent` apenas tinted (alpha bajo) — sobrio, no chillón.
9//!
10//! Como toda la elegancia de Llimphi, hereda de `llimphi-theme::motion`
11//! para la transición de fill al togglear (animación implícita via
12//! `View::animated(key, motion::MICRO)`).
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::string::ToString;
27use llimphi_compositor::llimphi_layout::taffy::{
28 prelude::{auto, length, FlexDirection, Size, Style},
29 AlignItems, JustifyContent, Rect,
30};
31use llimphi_compositor::peniko::Color;
32use llimphi_compositor::llimphi_text::Alignment;
33use llimphi_compositor::View;
34use llimphi_theme::{motion, radius, Theme};
35
36/// Paleta del chip — tres slots: idle, selected, fg.
37#[derive(Debug, Clone, Copy)]
38pub struct ChipPalette {
39 pub bg_idle: Color,
40 pub bg_selected: Color,
41 pub fg_idle: Color,
42 pub fg_selected: Color,
43 pub border: Color,
44}
45
46impl ChipPalette {
47 pub fn from_theme(t: &Theme) -> Self {
48 // El selected es el accent atenuado hacia el fondo —"tinte", no
49 // bloque sólido—. Calculado en sRGB-ish: `accent · 0.32 + bg · 0.68`.
50 let a = t.accent.components;
51 let b = t.bg_panel.components;
52 let mix = Color {
53 components: [
54 a[0] * 0.32 + b[0] * 0.68,
55 a[1] * 0.32 + b[1] * 0.68,
56 a[2] * 0.32 + b[2] * 0.68,
57 1.0,
58 ],
59 ..t.accent
60 };
61 Self {
62 bg_idle: t.bg_button,
63 bg_selected: mix,
64 fg_idle: t.fg_text,
65 fg_selected: t.fg_text,
66 border: t.border,
67 }
68 }
69}
70
71/// Sabor del chip — decide cómo se interpreta el `selected` y si lleva
72/// botón × removible.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ChipKind {
75 /// Toggle binario (selected ↔ no). Usado en `FilterChip` de Material.
76 Filter,
77 /// Radio dentro de un grupo (selected = exclusive). Visualmente igual
78 /// a Filter; la diferencia está en el caller que mantiene 1 seleccionado.
79 Choice,
80 /// Etiqueta con × final que dispara `on_remove`. Para tags, multi-select
81 /// presentado como contenedor de chips.
82 Input,
83 /// Acción rápida (no togglea); selected siempre falso.
84 Assist,
85}
86
87/// Chip base — devuelve el view con anim implícita de fill por estado.
88/// `key` debe ser estable entre frames del mismo chip (idx + grupo).
89pub fn chip_view<Msg: Clone + 'static>(
90 label: impl Into<String>,
91 kind: ChipKind,
92 selected: bool,
93 key: u64,
94 palette: &ChipPalette,
95 on_click: Msg,
96 on_remove: Option<Msg>,
97) -> View<Msg> {
98 chip_view_icon(None::<String>, label, kind, selected, key, palette, on_click, on_remove)
99}
100
101/// Como [`chip_view`] pero con **glyph líder** opcional (el "ícono opcional"
102/// que el assist-chip promete). `icon` `None` = sin ícono (idéntico a
103/// [`chip_view`]).
104#[allow(clippy::too_many_arguments)]
105pub fn chip_view_icon<Msg: Clone + 'static>(
106 icon: Option<impl Into<String>>,
107 label: impl Into<String>,
108 kind: ChipKind,
109 selected: bool,
110 key: u64,
111 palette: &ChipPalette,
112 on_click: Msg,
113 on_remove: Option<Msg>,
114) -> View<Msg> {
115 let bg = if selected { palette.bg_selected } else { palette.bg_idle };
116 let fg = if selected { palette.fg_selected } else { palette.fg_idle };
117
118 let label: String = label.into();
119 let aria = label.clone();
120 let mut children: Vec<View<Msg>> = Vec::with_capacity(3);
121 if let Some(g) = icon {
122 children.push(
123 View::new(Style {
124 size: Size { width: auto(), height: auto() },
125 margin: Rect {
126 left: length(0.0_f32),
127 right: length(5.0_f32),
128 top: length(0.0_f32),
129 bottom: length(0.0_f32),
130 },
131 ..Default::default()
132 })
133 .text_aligned(g.into(), 12.0, fg, Alignment::Center),
134 );
135 }
136 children.push(
137 View::new(Style {
138 size: Size { width: auto(), height: auto() },
139 ..Default::default()
140 })
141 .text_aligned(label, 12.0, fg, Alignment::Center),
142 );
143
144 if let (ChipKind::Input, Some(rm)) = (kind, on_remove) {
145 // ×: glifo pequeño a la derecha con on_click propio. No es
146 // botón nested (View no soporta nested on_click), así que es
147 // un nodo hermano con on_click separado dentro del mismo chip
148 // — el árbitro de hit-test elige el más interno.
149 children.push(
150 View::new(Style {
151 size: Size { width: length(14.0_f32), height: length(14.0_f32) },
152 margin: Rect {
153 left: length(6.0_f32),
154 right: length(0.0_f32),
155 top: length(0.0_f32),
156 bottom: length(0.0_f32),
157 },
158 align_items: Some(AlignItems::CENTER),
159 justify_content: Some(JustifyContent::CENTER),
160 ..Default::default()
161 })
162 .radius(7.0)
163 .hover_fill(palette.border)
164 .text_aligned("×".to_string(), 12.0, fg, Alignment::Center)
165 .on_click(rm)
166 .cursor(llimphi_compositor::Cursor::Pointer),
167 );
168 }
169
170 View::new(Style {
171 flex_direction: FlexDirection::Row,
172 align_items: Some(AlignItems::CENTER),
173 padding: Rect {
174 left: length(10.0_f32),
175 right: length(10.0_f32),
176 top: length(0.0_f32),
177 bottom: length(0.0_f32),
178 },
179 size: Size { width: auto(), height: length(24.0_f32) },
180 ..Default::default()
181 })
182 .fill(bg)
183 .radius(radius::XL)
184 .border(1.0, palette.border)
185 .animated(key, motion::MICRO)
186 .children(children)
187 // Filter chips se reportan como botones de toggle (pressed = selected);
188 // los input/assist chips son botones plain. AccessKit anuncia el estado
189 // de toggle al lector, así "está seleccionado / no seleccionado" sale solo.
190 .role(llimphi_compositor::Role::Button)
191 .aria_label(aria)
192 .aria_pressed(selected)
193 .on_click(on_click)
194 .cursor(llimphi_compositor::Cursor::Pointer)
195}
196
197/// Atajo: chip filter (toggle).
198pub fn filter_chip<Msg: Clone + 'static>(
199 label: impl Into<String>,
200 selected: bool,
201 key: u64,
202 palette: &ChipPalette,
203 on_toggle: Msg,
204) -> View<Msg> {
205 chip_view(label, ChipKind::Filter, selected, key, palette, on_toggle, None)
206}
207
208/// Atajo: chip input (removible).
209pub fn input_chip<Msg: Clone + 'static>(
210 label: impl Into<String>,
211 key: u64,
212 palette: &ChipPalette,
213 on_click: Msg,
214 on_remove: Msg,
215) -> View<Msg> {
216 chip_view(
217 label,
218 ChipKind::Input,
219 false,
220 key,
221 palette,
222 on_click,
223 Some(on_remove),
224 )
225}
226
227/// Atajo: chip assist (acción).
228pub fn assist_chip<Msg: Clone + 'static>(
229 label: impl Into<String>,
230 key: u64,
231 palette: &ChipPalette,
232 on_click: Msg,
233) -> View<Msg> {
234 chip_view(label, ChipKind::Assist, false, key, palette, on_click, None)
235}
236
237/// Atajo: chip assist con **glyph líder** (el "chip-acción con ícono" del
238/// docstring del módulo).
239pub fn assist_chip_icon<Msg: Clone + 'static>(
240 icon: impl Into<String>,
241 label: impl Into<String>,
242 key: u64,
243 palette: &ChipPalette,
244 on_click: Msg,
245) -> View<Msg> {
246 chip_view_icon(Some(icon), label, ChipKind::Assist, false, key, palette, on_click, None)
247}