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-color-picker` — selector de color RGBA agnóstico.
2//!
3//! Compone tres piezas, de arriba abajo:
4//! 1. un **swatch** del color actual,
5//! 2. una **paleta de chips** preestablecidos (clic = fija el RGB conservando
6//! el alfa actual), envuelta si no entra en una fila,
7//! 3. cuatro **sliders RGBA** para el ajuste fino.
8//!
9//! Es agnóstico: no sabe de config ni de `FieldValue`. Recibe el color como
10//! `[u8; 4]` y emite el color nuevo por `on_change([u8; 4]) -> Msg`. Cualquier
11//! app llimphi lo usa pasando su propio `Msg`.
12//!
13//! ```ignore
14//! color_picker_view(
15//! self.border,
16//! DEFAULT_SWATCHES,
17//! &ColorPickerPalette::from_theme(&theme),
18//! |rgba| Msg::SetBorderColor(rgba),
19//! )
20//! ```
21
22#![forbid(unsafe_code)]
23
24
25// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
26// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
27// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
28#![no_std]
29
30extern crate alloc;
31
32use alloc::string::String;
33use alloc::vec::Vec;
34use alloc::vec;
35use alloc::format;
36use alloc::string::ToString;
37mod cone;
38mod popup;
39pub use cone::{cone_facets, project_cone_point, ConeFacet, ConeOrbit, CONE_RINGS, CONE_SEGMENTS};
40pub use popup::{
41 alpha_strip_view, apply_scrub, color_popup_view, color_trigger_view, hsv_cone_view,
42 hue_strip_view, push_recent, scrub_channel_value, scrub_field_view, sv_pad_view,
43 ColorPopupPalette, ColorPopupSpec, ScrubChannel,
44};
45
46use llimphi_compositor::llimphi_layout::taffy::{
47 prelude::{length, percent, Dimension, FlexDirection, Size, Style},
48 FlexWrap, Rect,
49};
50use llimphi_compositor::peniko::Color;
51use llimphi_compositor::{DragPhase, View};
52use llimphi_widget_slider::{slider_view, SliderPalette};
53use llimphi_widget_text_input::{
54 text_input_view, text_input_view_full, TextInputEvent, TextInputPalette, TextInputState,
55};
56use alloc::sync::Arc;
57
58/// Paleta del color-picker: la del slider RGBA + los bordes de los chips + la
59/// del campo hex.
60#[derive(Debug, Clone, Copy)]
61pub struct ColorPickerPalette {
62 /// Paleta de los sliders RGBA.
63 pub slider: SliderPalette,
64 /// Borde de un chip inactivo.
65 pub chip_border: Color,
66 /// Borde del chip activo (el que coincide con el color actual).
67 pub chip_border_active: Color,
68 /// Paleta del input de texto del campo hex.
69 pub hex_input: TextInputPalette,
70 /// Color del rótulo "#" del campo hex.
71 pub hex_label: Color,
72}
73
74impl Default for ColorPickerPalette {
75 fn default() -> Self {
76 Self::from_theme(&llimphi_theme::Theme::dark())
77 }
78}
79
80impl ColorPickerPalette {
81 /// Construye la paleta desde un `Theme` semántico.
82 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
83 Self {
84 slider: SliderPalette::from_theme(t),
85 chip_border: t.border,
86 chip_border_active: t.accent,
87 hex_input: TextInputPalette::from_theme(t),
88 hex_label: t.fg_muted,
89 }
90 }
91}
92
93/// Estado de edición del campo **hex** del picker, que el caller posee (igual
94/// que [`text_input_view`]): si está focado, el buffer prestado, y el mensaje al
95/// clickearlo. El caller enruta las teclas a su `TextInputState` y reconstruye
96/// el color con [`parse_hex`].
97pub struct HexField<'a, Msg> {
98 /// Si el campo hex recibe teclas ahora.
99 pub focused: bool,
100 /// Buffer prestado por el caller mientras se edita.
101 pub state: Option<&'a TextInputState>,
102 /// Mensaje al clickear el campo **sin foco** (el caller arranca a editarlo).
103 pub on_focus: Msg,
104 /// Envuelve los eventos de **mouse** del campo focado (caret / doble-click →
105 /// palabra / arrastre → selección). El caller los aplica con `state.handle`.
106 /// Así el hex tiene el MISMO comportamiento completo que cualquier text-input.
107 pub on_input: Arc<dyn Fn(TextInputEvent) -> Msg + Send + Sync>,
108}
109
110/// `#RRGGBB` del color (descarta el alfa, que se edita con el slider A).
111pub fn rgba_to_hex(rgba: [u8; 4]) -> String {
112 format!("#{:02X}{:02X}{:02X}", rgba[0], rgba[1], rgba[2])
113}
114
115/// Parsea una cadena hex a RGBA. Acepta `#` opcional y 3/6/8 dígitos
116/// (`RGB`/`RRGGBB`/`RRGGBBAA`). Sin alfa explícito conserva `cur_alpha`. `None`
117/// si no es un hex válido (p. ej. mientras se escribe a medias).
118pub fn parse_hex(s: &str, cur_alpha: u8) -> Option<[u8; 4]> {
119 let h = s.trim().trim_start_matches('#');
120 let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
121 match h.len() {
122 3 => {
123 // RGB corto: cada dígito se duplica.
124 let d = |i: usize| u8::from_str_radix(&h[i..i + 1], 16).ok().map(|v| v * 17);
125 Some([d(0)?, d(1)?, d(2)?, cur_alpha])
126 }
127 6 => Some([byte(0)?, byte(2)?, byte(4)?, cur_alpha]),
128 8 => Some([byte(0)?, byte(2)?, byte(4)?, byte(6)?]),
129 _ => None,
130 }
131}
132
133/// Paleta de colores preestablecidos: grises + una rampa de tonos saturados,
134/// los típicos para marcos/acentos. El caller puede pasar la suya propia.
135pub const DEFAULT_SWATCHES: &[[u8; 3]] = &[
136 [0xEC, 0xEC, 0xEC],
137 [0x9E, 0x9E, 0x9E],
138 [0x42, 0x42, 0x42],
139 [0x5C, 0x8F, 0xEB],
140 [0x00, 0xBC, 0xD4],
141 [0x4C, 0xAF, 0x50],
142 [0xFF, 0xC1, 0x07],
143 [0xFF, 0x98, 0x00],
144 [0xF4, 0x43, 0x36],
145 [0xE9, 0x1E, 0x63],
146 [0x9C, 0x27, 0xB0],
147 [0x79, 0x55, 0x48],
148];
149
150/// Alto de la barra de tono (px).
151const HUE_BAR_H: f32 = 16.0;
152
153/// Alto fijo del picker (px): swatch + paleta (hasta 2 filas) + barra de tono +
154/// 4 sliders (+ campo hex si `with_hex`). Para estimar el alto en un contenedor
155/// con scroll.
156pub fn color_picker_height(with_hex: bool) -> f32 {
157 let hex = if with_hex { 38.0 } else { 0.0 };
158 16.0 + 54.0 + (HUE_BAR_H + 6.0) + 4.0 * 24.0 + hex
159}
160
161/// Compone el selector completo. `rgba` es el color actual; `swatches` la paleta
162/// de chips (p. ej. [`DEFAULT_SWATCHES`]); `on_change` recibe el color nuevo.
163/// Si `hex` es `Some`, agrega un campo de texto `#RRGGBB` editable al pie (su
164/// foco lo posee el caller — ver [`HexField`]). `None` = sin campo hex.
165pub fn color_picker_view<Msg, F>(
166 rgba: [u8; 4],
167 swatches: &[[u8; 3]],
168 palette: &ColorPickerPalette,
169 hex: Option<HexField<Msg>>,
170 on_change: F,
171) -> View<Msg>
172where
173 Msg: Clone + Send + Sync + 'static,
174 F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
175{
176 let mut rows: Vec<View<Msg>> = Vec::with_capacity(8);
177 rows.push(swatch_view(rgba));
178 rows.push(swatch_palette(rgba, swatches, palette, &on_change));
179 rows.push(hue_bar(rgba, palette, on_change.clone()));
180 if let Some(hex) = hex {
181 rows.push(hex_row(rgba, hex, palette));
182 }
183 for (ci, name) in [(0usize, "R"), (1, "G"), (2, "B"), (3, "A")] {
184 let f = on_change.clone();
185 rows.push(slider_view(
186 name.to_string(),
187 rgba[ci] as f32,
188 0.0,
189 255.0,
190 &palette.slider,
191 move |phase, dv| match phase {
192 DragPhase::Move => {
193 let nv = (rgba[ci] as f64 + dv as f64).clamp(0.0, 255.0) as u8;
194 let mut c = rgba;
195 c[ci] = nv;
196 Some(f(c))
197 }
198 DragPhase::End => None,
199 },
200 ));
201 }
202
203 View::new(Style {
204 flex_direction: FlexDirection::Column,
205 size: Size {
206 width: percent(1.0_f32),
207 height: Dimension::auto(),
208 },
209 gap: Size {
210 width: length(0.0_f32),
211 height: length(2.0_f32),
212 },
213 ..Default::default()
214 })
215 .children(rows)
216}
217
218/// La fila del campo hex `#RRGGBB` como pieza reusable (la usa el popover).
219/// Ver [`hex_row`].
220pub fn hex_row_pub<Msg: Clone + 'static>(
221 rgba: [u8; 4],
222 hex: HexField<Msg>,
223 palette: &ColorPickerPalette,
224) -> View<Msg> {
225 hex_row(rgba, hex, palette)
226}
227
228/// La fila del campo hex `#RRGGBB`: un input de ancho fijo. Cuando está focado
229/// usa el buffer prestado; si no, muestra el hex del color actual.
230fn hex_row<Msg: Clone + 'static>(
231 rgba: [u8; 4],
232 hex: HexField<Msg>,
233 palette: &ColorPickerPalette,
234) -> View<Msg> {
235 let input = match (hex.focused, hex.state) {
236 // Focado: comportamiento COMPLETO (caret/doble-click/selección) via el wrap.
237 (true, Some(st)) => {
238 let on_input = hex.on_input;
239 text_input_view_full(st, "", true, &palette.hex_input, move |ev| on_input(ev))
240 }
241 // Sin foco: display en reposo; un click lo enfoca.
242 _ => {
243 let mut tmp = TextInputState::new();
244 tmp.set_text(rgba_to_hex(rgba));
245 text_input_view(&tmp, "", false, &palette.hex_input, hex.on_focus)
246 }
247 };
248 let _ = palette.hex_label; // reservado por si se agrega un rótulo "#" aparte
249 View::new(Style {
250 size: Size {
251 width: length(120.0_f32),
252 height: length(34.0_f32),
253 },
254 margin: Rect {
255 left: length(0.0_f32),
256 right: length(0.0_f32),
257 top: length(3.0_f32),
258 bottom: length(1.0_f32),
259 },
260 ..Default::default()
261 })
262 .children(vec![input])
263}
264
265/// El swatch (muestra) del color actual.
266fn swatch_view<Msg: Clone + 'static>(rgba: [u8; 4]) -> View<Msg> {
267 View::new(Style {
268 size: Size {
269 width: length(40.0_f32),
270 height: length(16.0_f32),
271 },
272 ..Default::default()
273 })
274 .fill(Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]))
275 .radius(3.0)
276}
277
278/// La fila (envuelta) de chips de la paleta.
279fn swatch_palette<Msg, F>(
280 cur: [u8; 4],
281 swatches: &[[u8; 3]],
282 palette: &ColorPickerPalette,
283 on_change: &F,
284) -> View<Msg>
285where
286 Msg: Clone + Send + Sync + 'static,
287 F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
288{
289 let chips: Vec<View<Msg>> = swatches
290 .iter()
291 .map(|rgb| swatch_chip(*rgb, cur, palette, on_change))
292 .collect();
293 View::new(Style {
294 flex_direction: FlexDirection::Row,
295 flex_wrap: FlexWrap::Wrap,
296 size: Size {
297 width: percent(1.0_f32),
298 height: Dimension::auto(),
299 },
300 gap: Size {
301 width: length(5.0_f32),
302 height: length(5.0_f32),
303 },
304 margin: Rect {
305 left: length(0.0_f32),
306 right: length(0.0_f32),
307 top: length(3.0_f32),
308 bottom: length(2.0_f32),
309 },
310 ..Default::default()
311 })
312 .children(chips)
313}
314
315/// Un chip de la paleta: cuadrado clickeable. Si su RGB coincide con el color
316/// actual, lleva borde de acento.
317fn swatch_chip<Msg, F>(
318 rgb: [u8; 3],
319 cur: [u8; 4],
320 palette: &ColorPickerPalette,
321 on_change: &F,
322) -> View<Msg>
323where
324 Msg: Clone + Send + Sync + 'static,
325 F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
326{
327 let active = cur[0] == rgb[0] && cur[1] == rgb[1] && cur[2] == rgb[2];
328 // Conserva el alfa actual al elegir un chip.
329 let new_color = [rgb[0], rgb[1], rgb[2], cur[3]];
330 View::new(Style {
331 size: Size {
332 width: length(22.0_f32),
333 height: length(22.0_f32),
334 },
335 flex_shrink: 0.0,
336 ..Default::default()
337 })
338 .fill(Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255))
339 .radius(5.0)
340 .border(
341 if active { 2.0 } else { 1.0 },
342 if active {
343 palette.chip_border_active
344 } else {
345 palette.chip_border
346 },
347 )
348 .on_click(on_change(new_color))
349}
350
351/// La **barra de tono** (HSV): un degradé del arcoíris arrastrable. Mover el
352/// cursor cambia sólo el tono (H), conservando saturación, valor y alfa. Un
353/// thumb marca el tono actual. Si el color es un gris (S≈0) se asume S=1 al
354/// pintar para que de un gris se pueda "entrar" a un color.
355fn hue_bar<Msg, F>(rgba: [u8; 4], palette: &ColorPickerPalette, on_change: F) -> View<Msg>
356where
357 Msg: Clone + Send + Sync + 'static,
358 F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
359{
360 let width = palette.slider.track_width.max(1.0);
361 let (h, mut s, mut v) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
362 if s < 0.02 {
363 // Desde un gris/blanco, dejar entrar a un tono saturado.
364 s = 1.0;
365 if v < 0.02 {
366 v = 1.0;
367 }
368 }
369 let alpha = rgba[3];
370 let thumb_ratio = h / 360.0;
371
372 // Handler de drag: dx → dh (proporcional al ancho), nuevo tono.
373 let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
374 match phase {
375 DragPhase::Move => {
376 let dh = dx / width * 360.0;
377 let nh = (h + dh).rem_euclid(360.0);
378 let [r, g, b] = hsv_to_rgb(nh, s, v);
379 Some(on_change([r, g, b, alpha]))
380 }
381 DragPhase::End => None,
382 }
383 };
384
385 View::new(Style {
386 size: Size {
387 width: length(width),
388 height: length(HUE_BAR_H),
389 },
390 flex_shrink: 0.0,
391 ..Default::default()
392 })
393 .radius(4.0)
394 .draggable(handler)
395 .paint_with(move |scene, _ts, rect| {
396 use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
397 use llimphi_compositor::peniko::{Fill, Gradient};
398 if rect.w <= 0.0 || rect.h <= 0.0 {
399 return;
400 }
401 let x0 = rect.x as f64;
402 let y0 = rect.y as f64;
403 let x1 = (rect.x + rect.w) as f64;
404 let y1 = (rect.y + rect.h) as f64;
405 let rr = RoundedRect::new(x0, y0, x1, y1, 4.0);
406 // Degradé del arcoíris: 7 paradas (rojo→amarillo→verde→cian→azul→
407 // magenta→rojo) distribuidas parejo.
408 let stops = [
409 Color::from_rgba8(255, 0, 0, 255),
410 Color::from_rgba8(255, 255, 0, 255),
411 Color::from_rgba8(0, 255, 0, 255),
412 Color::from_rgba8(0, 255, 255, 255),
413 Color::from_rgba8(0, 0, 255, 255),
414 Color::from_rgba8(255, 0, 255, 255),
415 Color::from_rgba8(255, 0, 0, 255),
416 ];
417 let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
418 .with_stops(stops.as_slice());
419 scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
420 // Thumb: línea vertical blanca en la posición del tono.
421 let tx = x0 + (x1 - x0) * thumb_ratio as f64;
422 let thumb = RoundedRect::new(tx - 1.5, y0 - 1.0, tx + 1.5, y1 + 1.0, 1.5);
423 let white = Color::from_rgba8(255, 255, 255, 230);
424 scene.fill(Fill::NonZero, Affine::IDENTITY, &white, None, &thumb);
425 })
426}
427
428/// Convierte RGB (`[u8;3]`) a HSV: `(h en 0..360, s en 0..1, v en 0..1)`.
429pub fn rgb_to_hsv(rgb: [u8; 3]) -> (f32, f32, f32) {
430 let r = rgb[0] as f32 / 255.0;
431 let g = rgb[1] as f32 / 255.0;
432 let b = rgb[2] as f32 / 255.0;
433 let max = r.max(g).max(b);
434 let min = r.min(g).min(b);
435 let d = max - min;
436 let v = max;
437 let s = if max <= 0.0 { 0.0 } else { d / max };
438 let h = if d <= 0.0 {
439 0.0
440 } else if max == r {
441 60.0 * (((g - b) / d).rem_euclid(6.0))
442 } else if max == g {
443 60.0 * ((b - r) / d + 2.0)
444 } else {
445 60.0 * ((r - g) / d + 4.0)
446 };
447 (h.rem_euclid(360.0), s, v)
448}
449
450/// Convierte HSV (`h en 0..360, s,v en 0..1`) a RGB (`[u8;3]`).
451pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [u8; 3] {
452 let c = v * s;
453 let hp = (h.rem_euclid(360.0)) / 60.0;
454 let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
455 let (r1, g1, b1) = match hp as u32 {
456 0 => (c, x, 0.0),
457 1 => (x, c, 0.0),
458 2 => (0.0, c, x),
459 3 => (0.0, x, c),
460 4 => (x, 0.0, c),
461 _ => (c, 0.0, x),
462 };
463 let m = v - c;
464 let to_u8 = |t: f32| ((t + m) * 255.0).round().clamp(0.0, 255.0) as u8;
465 [to_u8(r1), to_u8(g1), to_u8(b1)]
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn hsv_roundtrip_colores_puros() {
474 for rgb in [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255]] {
475 let (h, s, v) = rgb_to_hsv(rgb);
476 assert_eq!(hsv_to_rgb(h, s, v), rgb, "roundtrip {rgb:?}");
477 }
478 }
479
480 #[test]
481 fn gris_tiene_saturacion_cero() {
482 let (_, s, v) = rgb_to_hsv([128, 128, 128]);
483 assert!(s < 0.01);
484 assert!((v - 128.0 / 255.0).abs() < 0.01);
485 }
486
487 #[test]
488 fn tono_rojo_es_cero_grados() {
489 let (h, _, _) = rgb_to_hsv([255, 0, 0]);
490 assert!(h < 0.5 || h > 359.5);
491 }
492
493 #[test]
494 fn hex_roundtrip_y_parse() {
495 assert_eq!(rgba_to_hex([92, 143, 235, 255]), "#5C8FEB");
496 assert_eq!(parse_hex("#5C8FEB", 255), Some([92, 143, 235, 255]));
497 assert_eq!(parse_hex("5c8feb", 200), Some([92, 143, 235, 200])); // sin #, conserva alfa
498 assert_eq!(parse_hex("#FFF", 255), Some([255, 255, 255, 255])); // corto
499 assert_eq!(parse_hex("#5C8FEB80", 255), Some([92, 143, 235, 128])); // con alfa
500 assert_eq!(parse_hex("#5C8", 255), Some([0x55, 0xCC, 0x88, 255])); // corto RGB
501 assert_eq!(parse_hex("#5C8F", 255), None); // 4 dígitos: inválido
502 assert_eq!(parse_hex("zz", 255), None);
503 assert_eq!(parse_hex("#5C8FE", 255), None); // a medias
504 }
505}