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//! `popup` — el selector de color **en ventanita flotante** (popover): el
2//! hermano rico del [`color_picker_view`](crate::color_picker_view) inline.
3//!
4//! Se monta en [`App::view_overlay`](llimphi_compositor::App::view_overlay), como el
5//! `select` o el `context-menu`: un scrim full-screen que dismissa al
6//! click-fuera + un panel absoluto anclado bajo el disparador. Contiene, de
7//! arriba abajo:
8//!
9//! 1. **Cono HSV/HSB en 3D** ([`hsv_cone_view`]) — el sólido de color orbitable
10//! con arrastre, con un marcador en la posición del color actual. La
11//! innovación: ver *dónde* cae tu color dentro del espacio HSV.
12//! 2. **Panel S/V** ([`sv_pad_view`]) — saturación (x) × valor (y) del tono
13//! actual, arrastrable.
14//! 3. **Barra de tono** ([`hue_strip_view`]) y **barra de alfa**
15//! ([`alpha_strip_view`]).
16//! 4. **Colores recientes** — caché que el caller posee (ver [`push_recent`]) +
17//! paleta de presets.
18//! 5. **Campos numéricos** — hex editable + valores **R G B** y **H S V**
19//! *scrubbables* (arrastre vertical, sin foco de teclado) ([`scrub_field_view`]).
20//!
21//! Es agnóstico como el resto: recibe `[u8; 4]` y emite el color nuevo por
22//! callbacks `Msg`. Todo el estado (color actual, órbita, recientes, foco del
23//! hex) lo posee el caller.
24
25use alloc::vec::Vec;
26use alloc::vec;
27use alloc::format;
28use alloc::string::ToString;
29use alloc::sync::Arc;
30// `spin` y no `std::sync`: el drag corre en el hilo de la UI y el mutex no
31// arbitra nada — sólo aporta la mutabilidad interior `Sync` que el handler
32// exige. El de spin la da sin sistema operativo.
33use spin::Mutex;
34
35use llimphi_compositor::llimphi_layout::taffy::{
36 prelude::{auto, length, percent, Dimension, FlexDirection, Position, Size, Style},
37 AlignItems, FlexWrap, JustifyContent, Rect,
38};
39use llimphi_compositor::peniko::Color;
40use llimphi_compositor::llimphi_text::Alignment;
41use llimphi_compositor::{DragPhase, View};
42
43use crate::cone::{cone_facets, hsv_model_point, project_cone_point, ConeOrbit};
44use crate::{
45 hex_row_pub, rgb_to_hsv, hsv_to_rgb, ColorPickerPalette, HexField,
46};
47
48// ─────────────────────────────────────────────────────────────────────────
49// Dimensiones del panel (anchos fijos → los deltas de arrastre escalan bien).
50// ─────────────────────────────────────────────────────────────────────────
51
52const PANEL_W: f32 = 300.0;
53const PAD: f32 = 12.0;
54/// Ancho interior útil (lo que ocupan cono/pad/barras).
55const INNER: f32 = PANEL_W - 2.0 * PAD;
56const CONE_H: f32 = 190.0;
57const SV_H: f32 = 150.0;
58const STRIP_H: f32 = 16.0;
59const GAP: f32 = 8.0;
60/// Estimación del alto del panel para acotarlo al viewport (contenido real es
61/// auto; esto sólo posiciona).
62const PANEL_H_EST: f32 = 640.0;
63
64// ─────────────────────────────────────────────────────────────────────────
65// Paleta
66// ─────────────────────────────────────────────────────────────────────────
67
68/// Paleta del popover, derivada del `Theme`.
69#[derive(Debug, Clone, Copy)]
70pub struct ColorPopupPalette {
71 /// Paleta del picker inline (sliders/hex/chips) — reusada.
72 pub picker: ColorPickerPalette,
73 /// Fondo del panel elevado.
74 pub panel: Color,
75 /// Fondo de un chip/campo hundido.
76 pub inset: Color,
77 /// Borde del panel y de los campos.
78 pub border: Color,
79 /// Scrim translúcido de dismiss.
80 pub scrim: Color,
81 /// Texto normal / atenuado / acento.
82 pub fg_text: Color,
83 pub fg_muted: Color,
84 pub accent: Color,
85 /// Contorno de los marcadores sobre cono / pad / barras.
86 pub marker: Color,
87 /// Radio de las esquinas del panel.
88 pub radius: f64,
89}
90
91impl Default for ColorPopupPalette {
92 fn default() -> Self {
93 Self::from_theme(&llimphi_theme::Theme::dark())
94 }
95}
96
97impl ColorPopupPalette {
98 /// Construye la paleta desde un `Theme` semántico.
99 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
100 Self {
101 picker: ColorPickerPalette::from_theme(t),
102 panel: t.bg_panel,
103 inset: t.bg_input,
104 border: t.border,
105 scrim: Color::from_rgba8(0, 0, 0, 96),
106 fg_text: t.fg_text,
107 fg_muted: t.fg_muted,
108 accent: t.accent,
109 marker: Color::from_rgba8(255, 255, 255, 235),
110 radius: 10.0,
111 }
112 }
113}
114
115// ─────────────────────────────────────────────────────────────────────────
116// Caché de colores recientes
117// ─────────────────────────────────────────────────────────────────────────
118
119/// Empuja `color` al frente de la caché de recientes que el caller posee,
120/// deduplicando (si ya estaba, sube al frente) y recortando a `cap`. Llamalo
121/// cuando el usuario "confirma" un color (cierra el popover, suelta el drag,
122/// etc.) — no en cada micro-cambio del arrastre.
123pub fn push_recent(list: &mut Vec<[u8; 4]>, color: [u8; 4], cap: usize) {
124 list.retain(|c| *c != color);
125 list.insert(0, color);
126 if list.len() > cap {
127 list.truncate(cap);
128 }
129}
130
131// ─────────────────────────────────────────────────────────────────────────
132// Popover completo
133// ─────────────────────────────────────────────────────────────────────────
134
135/// Todo lo que el popover necesita, prestado del `Model` del caller.
136pub struct ColorPopupSpec<'a, Msg: Clone + 'static> {
137 /// Esquina superior-izquierda deseada (típico: bajo el disparador).
138 pub anchor: (f32, f32),
139 /// Tamaño del viewport, para acotar el panel a la pantalla.
140 pub viewport: (f32, f32),
141 /// Color actual.
142 pub rgba: [u8; 4],
143 /// Órbita del cono 3D (la posee el caller).
144 pub orbit: ConeOrbit,
145 /// Caché de recientes (ver [`push_recent`]). Puede ir vacía.
146 pub recent: &'a [[u8; 4]],
147 /// Presets fijos (p. ej. [`DEFAULT_SWATCHES`](crate::DEFAULT_SWATCHES)).
148 pub swatches: &'a [[u8; 3]],
149 /// Campo hex editable (foco del caller). `None` = sin campo hex.
150 pub hex: Option<HexField<'a, Msg>>,
151 pub palette: &'a ColorPopupPalette,
152 /// Nuevo color elegido (cualquier control).
153 pub on_change: Arc<dyn Fn([u8; 4]) -> Msg + Send + Sync>,
154 /// Arrastre sobre el cono: `(d_yaw, d_pitch)` en radianes.
155 pub on_orbit: Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>,
156 /// Click fuera (scrim) o Esc.
157 pub on_dismiss: Msg,
158}
159
160/// Compone el popover completo como `View<Msg>` para
161/// [`App::view_overlay`](llimphi_compositor::App::view_overlay).
162pub fn color_popup_view<Msg: Clone + Send + Sync + 'static>(
163 spec: ColorPopupSpec<'_, Msg>,
164) -> View<Msg> {
165 let ColorPopupSpec {
166 anchor,
167 viewport,
168 rgba,
169 orbit,
170 recent,
171 swatches,
172 hex,
173 palette,
174 on_change,
175 on_orbit,
176 on_dismiss,
177 } = spec;
178
179 let mut rows: Vec<View<Msg>> = Vec::with_capacity(10);
180
181 // 1. Cono HSV 3D.
182 rows.push(hsv_cone_view(rgba, orbit, (INNER, CONE_H), palette, on_orbit));
183 // 2. Pad S/V.
184 rows.push(sv_pad_view(rgba, (INNER, SV_H), palette, on_change.clone()));
185 // 3. Barras tono + alfa.
186 rows.push(hue_strip_view(rgba, INNER, palette, on_change.clone()));
187 rows.push(alpha_strip_view(rgba, INNER, palette, on_change.clone()));
188 // 4. Recientes + presets.
189 if !recent.is_empty() {
190 rows.push(section_label("Recientes", palette));
191 rows.push(chip_row(recent.iter().copied().collect(), rgba, palette, &on_change));
192 }
193 if !swatches.is_empty() {
194 rows.push(section_label("Presets", palette));
195 let cur_a = rgba[3];
196 let presets: Vec<[u8; 4]> =
197 swatches.iter().map(|c| [c[0], c[1], c[2], cur_a]).collect();
198 rows.push(chip_row(presets, rgba, palette, &on_change));
199 }
200 // 5. Numéricos: hex + R G B + H S V.
201 if let Some(hex) = hex {
202 rows.push(hex_row_pub(rgba, hex, &palette.picker));
203 }
204 rows.push(scrub_row(
205 rgba,
206 [ScrubChannel::R, ScrubChannel::G, ScrubChannel::B],
207 palette,
208 &on_change,
209 ));
210 rows.push(scrub_row(
211 rgba,
212 [ScrubChannel::H, ScrubChannel::S, ScrubChannel::V],
213 palette,
214 &on_change,
215 ));
216
217 // Clamp del ancla al viewport.
218 let margin = 6.0;
219 let x = anchor
220 .0
221 .min((viewport.0 - PANEL_W - margin).max(margin))
222 .max(margin);
223 let y = anchor
224 .1
225 .min((viewport.1 - PANEL_H_EST - margin).max(margin))
226 .max(margin);
227
228 // Panel: nodo exterior = borde; interior = fondo + padding + columna.
229 let panel = View::new(Style {
230 position: Position::Absolute,
231 inset: Rect { left: length(x), top: length(y), right: auto(), bottom: auto() },
232 size: Size { width: length(PANEL_W), height: Dimension::auto() },
233 padding: Rect {
234 left: length(1.0_f32),
235 right: length(1.0_f32),
236 top: length(1.0_f32),
237 bottom: length(1.0_f32),
238 },
239 ..Default::default()
240 })
241 .fill(palette.border)
242 .radius(palette.radius)
243 .children(vec![View::new(Style {
244 flex_direction: FlexDirection::Column,
245 size: Size { width: percent(1.0_f32), height: Dimension::auto() },
246 padding: Rect {
247 left: length(PAD),
248 right: length(PAD),
249 top: length(PAD),
250 bottom: length(PAD),
251 },
252 gap: Size { width: length(0.0_f32), height: length(GAP) },
253 align_items: Some(AlignItems::CENTER),
254 ..Default::default()
255 })
256 .fill(palette.panel)
257 .radius((palette.radius - 1.0).max(0.0))
258 .children(rows)]);
259
260 // Scrim full-screen que dismissa.
261 View::new(Style {
262 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
263 ..Default::default()
264 })
265 .fill(palette.scrim)
266 .on_click(on_dismiss)
267 .children(vec![panel])
268}
269
270// ─────────────────────────────────────────────────────────────────────────
271// El cono HSV/HSB en 3D
272// ─────────────────────────────────────────────────────────────────────────
273
274/// Sensibilidad del arrastre de órbita (radianes por px).
275const ORBIT_K: f32 = 0.01;
276
277/// El **cono HSV en 3D**: pinta el sólido de color con flat-shading + orden de
278/// pintor (painter), un marcador en la posición del color actual, y orbita con
279/// arrastre. `size` = `(ancho, alto)` del canvas en px. `on_orbit(d_yaw,
280/// d_pitch)` acumula el giro en el `Model` del caller.
281pub fn hsv_cone_view<Msg: Clone + Send + Sync + 'static>(
282 rgba: [u8; 4],
283 orbit: ConeOrbit,
284 size: (f32, f32),
285 palette: &ColorPopupPalette,
286 on_orbit: Arc<dyn Fn(f32, f32) -> Msg + Send + Sync>,
287) -> View<Msg> {
288 let (w, h) = size;
289 let facets = cone_facets();
290 let (hue, sat, val) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
291 let marker_col = palette.marker;
292
293 let handler = move |phase: DragPhase, dx: f32, dy: f32| -> Option<Msg> {
294 match phase {
295 DragPhase::Move => Some(on_orbit(dx * ORBIT_K, dy * ORBIT_K)),
296 DragPhase::End => None,
297 }
298 };
299
300 View::new(Style {
301 size: Size { width: length(w), height: length(h) },
302 flex_shrink: 0.0,
303 ..Default::default()
304 })
305 .draggable(handler)
306 .paint_with(move |scene, _ts, rect| {
307 use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Circle, Point};
308 use llimphi_compositor::peniko::Fill;
309 if rect.w <= 1.0 || rect.h <= 1.0 {
310 return;
311 }
312 let cx = (rect.x + rect.w * 0.5) as f64;
313 let cy = (rect.y + rect.h * 0.5) as f64;
314 let scale = (rect.w.min(rect.h) * 0.40) as f64;
315 let to_px = |m: [f32; 3]| -> (f64, f64, f32) {
316 let (sx, sy, depth) = project_cone_point(m, orbit);
317 (cx + sx as f64 * scale, cy - sy as f64 * scale, depth)
318 };
319
320 // Proyectar cada cara y ordenarla por profundidad (atrás → adelante).
321 let mut prepared: Vec<([(f64, f64); 4], f32, [u8; 3])> = facets
322 .iter()
323 .map(|f| {
324 let mut pts = [(0.0, 0.0); 4];
325 let mut dsum = 0.0;
326 for (i, c) in f.corners.iter().enumerate() {
327 let (px, py, d) = to_px(*c);
328 pts[i] = (px, py);
329 dsum += d;
330 }
331 (pts, dsum * 0.25, f.color)
332 })
333 .collect();
334 prepared.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
335
336 for (pts, depth, color) in &prepared {
337 // Sombreado sutil por profundidad: las caras del frente, más claras.
338 let shade = 0.74 + 0.26 * ((depth + 0.9) / 1.8).clamp(0.0, 1.0);
339 let sc = |c: u8| ((c as f32 * shade).round().clamp(0.0, 255.0)) as u8;
340 let col = Color::from_rgba8(sc(color[0]), sc(color[1]), sc(color[2]), 255);
341 let mut bp = BezPath::new();
342 bp.move_to(Point::new(pts[0].0, pts[0].1));
343 bp.line_to(Point::new(pts[1].0, pts[1].1));
344 bp.line_to(Point::new(pts[2].0, pts[2].1));
345 bp.line_to(Point::new(pts[3].0, pts[3].1));
346 bp.close_path();
347 scene.fill(Fill::NonZero, Affine::IDENTITY, &col, None, &bp);
348 }
349
350 // Marcador del color actual dentro del sólido.
351 let (mx, my, _) = to_px(hsv_model_point(hue, sat, val));
352 let center = Point::new(mx, my);
353 let dark = Color::from_rgba8(0, 0, 0, 235);
354 scene.fill(Fill::NonZero, Affine::IDENTITY, &dark, None, &Circle::new(center, 6.0));
355 let cur = Color::from_rgba8(rgba[0], rgba[1], rgba[2], 255);
356 scene.fill(Fill::NonZero, Affine::IDENTITY, &cur, None, &Circle::new(center, 4.5));
357 // Aro exterior claro.
358 let ring = kurbo_ring(center, 6.0, 1.3);
359 scene.fill(Fill::NonZero, Affine::IDENTITY, &marker_col, None, &ring);
360 })
361}
362
363/// Anillo (annulus) como `BezPath` de dos círculos con regla par-impar.
364fn kurbo_ring(
365 center: llimphi_compositor::peniko::kurbo::Point,
366 r_out: f64,
367 width: f64,
368) -> llimphi_compositor::peniko::kurbo::BezPath {
369 use llimphi_compositor::peniko::kurbo::{BezPath, Circle, Shape};
370 let mut bp = BezPath::new();
371 for e in Circle::new(center, r_out).path_elements(0.1) {
372 bp.push(e);
373 }
374 for e in Circle::new(center, (r_out - width).max(0.1)).path_elements(0.1) {
375 bp.push(e);
376 }
377 bp
378}
379
380// ─────────────────────────────────────────────────────────────────────────
381// Pad S/V (saturación × valor del tono actual)
382// ─────────────────────────────────────────────────────────────────────────
383
384/// El **pad Saturación/Valor**: un cuadrado con S en x (izq→der) y V en y
385/// (arriba→abajo), teñido con el tono actual. **Click salta** al punto exacto
386/// y **arrastrar** sigue al cursor (posición absoluta, no delta relativo). Un
387/// marcador anular señala `(S, V)`. `size` = `(ancho, alto)` en px.
388pub fn sv_pad_view<Msg: Clone + Send + Sync + 'static>(
389 rgba: [u8; 4],
390 size: (f32, f32),
391 palette: &ColorPopupPalette,
392 on_change: Arc<dyn Fn([u8; 4]) -> Msg + Send + Sync>,
393) -> View<Msg> {
394 let (w, h) = size;
395 // El pad opera sobre el tono actual; para un negro/gris el tono queda como
396 // esté (0 = rojo), permitiendo "entrar" a un color al mover S/V.
397 let (hue, sat, val) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
398 let alpha = rgba[3];
399 let marker_col = palette.marker;
400 let border = palette.border;
401
402 // Posición local → color, **absoluta** (S = x/w, V = 1 - y/h).
403 let at_to_color = move |px: f32, py: f32, rw: f32, rh: f32| -> [u8; 4] {
404 let s = (px / rw.max(1.0)).clamp(0.0, 1.0);
405 let v = (1.0 - py / rh.max(1.0)).clamp(0.0, 1.0);
406 let c = hsv_to_rgb(hue, s, v);
407 [c[0], c[1], c[2], alpha]
408 };
409
410 // Click: salta al punto exacto (fires on press).
411 let on_click = on_change.clone();
412 let click = move |lx: f32, ly: f32, rw: f32, rh: f32| -> Option<Msg> {
413 Some(on_click(at_to_color(lx, ly, rw, rh)))
414 };
415 // Drag: acumula la posición local (sembrada en el press `lx0/ly0`) para
416 // seguir al cursor de forma absoluta, sin depender del estado congelado.
417 let live: Arc<Mutex<Option<(f32, f32)>>> = Arc::new(Mutex::new(None));
418 let on_drag = on_change.clone();
419 let drag = move |phase: DragPhase, dx: f32, dy: f32, lx0: f32, ly0: f32| -> Option<Msg> {
420 match phase {
421 DragPhase::Move => {
422 let mut l = live.lock();
423 let cur = l.unwrap_or((lx0, ly0));
424 let np = ((cur.0 + dx).clamp(0.0, w), (cur.1 + dy).clamp(0.0, h));
425 *l = Some(np);
426 Some(on_drag(at_to_color(np.0, np.1, w, h)))
427 }
428 DragPhase::End => {
429 *live.lock() = None;
430 None
431 }
432 }
433 };
434
435 let pure = hsv_to_rgb(hue, 1.0, 1.0);
436 View::new(Style {
437 size: Size { width: length(w), height: length(h) },
438 flex_shrink: 0.0,
439 ..Default::default()
440 })
441 .radius(6.0)
442 .border(1.0, border)
443 // Los tres controles del picker se pintan enteros con `paint_with`: sin rol
444 // son nodos vacíos pese a ser lo único interactivo del popup.
445 .role(llimphi_compositor::Role::Slider)
446 .aria_label("Saturación y brillo")
447 .on_click_at(click)
448 .draggable_at(drag)
449 .paint_with(move |scene, _ts, rect| {
450 use llimphi_compositor::peniko::kurbo::{Affine, Circle, Point, RoundedRect};
451 use llimphi_compositor::peniko::{Fill, Gradient};
452 if rect.w <= 1.0 || rect.h <= 1.0 {
453 return;
454 }
455 let x0 = rect.x as f64;
456 let y0 = rect.y as f64;
457 let x1 = (rect.x + rect.w) as f64;
458 let y1 = (rect.y + rect.h) as f64;
459 let rr = RoundedRect::new(x0, y0, x1, y1, 6.0);
460 // Base: blanco (S=0) → tono puro (S=1), horizontal.
461 let white = Color::from_rgba8(255, 255, 255, 255);
462 let pure_c = Color::from_rgba8(pure[0], pure[1], pure[2], 255);
463 let gh = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
464 .with_stops([white, pure_c].as_slice());
465 scene.fill(Fill::NonZero, Affine::IDENTITY, &gh, None, &rr);
466 // Valor: transparente (V=1, arriba) → negro (V=0, abajo), vertical.
467 let clear = Color::from_rgba8(0, 0, 0, 0);
468 let black = Color::from_rgba8(0, 0, 0, 255);
469 let gv = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1))
470 .with_stops([clear, black].as_slice());
471 scene.fill(Fill::NonZero, Affine::IDENTITY, &gv, None, &rr);
472 // Marcador en (S, V).
473 let mx = x0 + (x1 - x0) * sat as f64;
474 let my = y0 + (y1 - y0) * (1.0 - val as f64);
475 let center = Point::new(mx.clamp(x0, x1), my.clamp(y0, y1));
476 let dark = Color::from_rgba8(0, 0, 0, 220);
477 scene.fill(Fill::NonZero, Affine::IDENTITY, &dark, None, &Circle::new(center, 6.0));
478 scene.fill(Fill::NonZero, Affine::IDENTITY, &marker_col, None, &kurbo_ring(center, 6.0, 1.5));
479 })
480}
481
482// ─────────────────────────────────────────────────────────────────────────
483// Barra de tono
484// ─────────────────────────────────────────────────────────────────────────
485
486/// La **barra de tono** (arcoíris): click/arrastre **absolutos** fijan H por
487/// la posición, conservando S/V/A. `width` en px (fijo).
488pub fn hue_strip_view<Msg: Clone + Send + Sync + 'static>(
489 rgba: [u8; 4],
490 width: f32,
491 palette: &ColorPopupPalette,
492 on_change: Arc<dyn Fn([u8; 4]) -> Msg + Send + Sync>,
493) -> View<Msg> {
494 let (h, mut s, mut v) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
495 if s < 0.02 {
496 s = 1.0;
497 if v < 0.02 {
498 v = 1.0;
499 }
500 }
501 let alpha = rgba[3];
502 let thumb_ratio = h / 360.0;
503 let marker_col = palette.marker;
504
505 // Tono absoluto = x/w · 360.
506 let at_to_color = move |px: f32, rw: f32| -> [u8; 4] {
507 let nh = (px / rw.max(1.0)).clamp(0.0, 1.0) * 360.0;
508 let c = hsv_to_rgb(nh, s, v);
509 [c[0], c[1], c[2], alpha]
510 };
511 let on_click = on_change.clone();
512 let click = move |lx: f32, _ly: f32, rw: f32, _rh: f32| -> Option<Msg> {
513 Some(on_click(at_to_color(lx, rw)))
514 };
515 let live: Arc<Mutex<Option<f32>>> = Arc::new(Mutex::new(None));
516 let on_drag = on_change.clone();
517 let drag = move |phase: DragPhase, dx: f32, _dy: f32, lx0: f32, _ly0: f32| -> Option<Msg> {
518 match phase {
519 DragPhase::Move => {
520 let mut l = live.lock();
521 let cur = l.unwrap_or(lx0);
522 let np = (cur + dx).clamp(0.0, width);
523 *l = Some(np);
524 Some(on_drag(at_to_color(np, width)))
525 }
526 DragPhase::End => {
527 *live.lock() = None;
528 None
529 }
530 }
531 };
532
533 View::new(Style {
534 size: Size { width: length(width), height: length(STRIP_H) },
535 flex_shrink: 0.0,
536 ..Default::default()
537 })
538 .radius(4.0)
539 .role(llimphi_compositor::Role::Slider)
540 .aria_label("Matiz")
541 .on_click_at(click)
542 .draggable_at(drag)
543 .paint_with(move |scene, _ts, rect| {
544 use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
545 use llimphi_compositor::peniko::{Fill, Gradient};
546 if rect.w <= 1.0 || rect.h <= 1.0 {
547 return;
548 }
549 let x0 = rect.x as f64;
550 let y0 = rect.y as f64;
551 let x1 = (rect.x + rect.w) as f64;
552 let y1 = (rect.y + rect.h) as f64;
553 let rr = RoundedRect::new(x0, y0, x1, y1, 4.0);
554 let stops = [
555 Color::from_rgba8(255, 0, 0, 255),
556 Color::from_rgba8(255, 255, 0, 255),
557 Color::from_rgba8(0, 255, 0, 255),
558 Color::from_rgba8(0, 255, 255, 255),
559 Color::from_rgba8(0, 0, 255, 255),
560 Color::from_rgba8(255, 0, 255, 255),
561 Color::from_rgba8(255, 0, 0, 255),
562 ];
563 let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
564 .with_stops(stops.as_slice());
565 scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
566 let tx = x0 + (x1 - x0) * thumb_ratio as f64;
567 let thumb = RoundedRect::new(tx - 1.5, y0 - 1.0, tx + 1.5, y1 + 1.0, 1.5);
568 scene.fill(Fill::NonZero, Affine::IDENTITY, &marker_col, None, &thumb);
569 })
570}
571
572// ─────────────────────────────────────────────────────────────────────────
573// Barra de alfa
574// ─────────────────────────────────────────────────────────────────────────
575
576/// La **barra de alfa**: damero + degradé del color actual de transparente a
577/// opaco. Click/arrastre **absolutos** fijan el alfa por la posición.
578pub fn alpha_strip_view<Msg: Clone + Send + Sync + 'static>(
579 rgba: [u8; 4],
580 width: f32,
581 palette: &ColorPopupPalette,
582 on_change: Arc<dyn Fn([u8; 4]) -> Msg + Send + Sync>,
583) -> View<Msg> {
584 let rgb = [rgba[0], rgba[1], rgba[2]];
585 let thumb_ratio = rgba[3] as f32 / 255.0;
586 let marker_col = palette.marker;
587
588 // Alfa absoluto = x/w · 255.
589 let at_to_color = move |px: f32, rw: f32| -> [u8; 4] {
590 let na = ((px / rw.max(1.0)).clamp(0.0, 1.0) * 255.0).round() as u8;
591 [rgb[0], rgb[1], rgb[2], na]
592 };
593 let on_click = on_change.clone();
594 let click = move |lx: f32, _ly: f32, rw: f32, _rh: f32| -> Option<Msg> {
595 Some(on_click(at_to_color(lx, rw)))
596 };
597 let live: Arc<Mutex<Option<f32>>> = Arc::new(Mutex::new(None));
598 let on_drag = on_change.clone();
599 let drag = move |phase: DragPhase, dx: f32, _dy: f32, lx0: f32, _ly0: f32| -> Option<Msg> {
600 match phase {
601 DragPhase::Move => {
602 let mut l = live.lock();
603 let cur = l.unwrap_or(lx0);
604 let np = (cur + dx).clamp(0.0, width);
605 *l = Some(np);
606 Some(on_drag(at_to_color(np, width)))
607 }
608 DragPhase::End => {
609 *live.lock() = None;
610 None
611 }
612 }
613 };
614
615 View::new(Style {
616 size: Size { width: length(width), height: length(STRIP_H) },
617 flex_shrink: 0.0,
618 ..Default::default()
619 })
620 .radius(4.0)
621 .role(llimphi_compositor::Role::Slider)
622 .aria_label("Transparencia")
623 .on_click_at(click)
624 .draggable_at(drag)
625 .paint_with(move |scene, _ts, rect| {
626 use llimphi_compositor::peniko::kurbo::{Affine, Point, Rect as KRect, RoundedRect};
627 use llimphi_compositor::peniko::{Fill, Gradient, Mix};
628 if rect.w <= 1.0 || rect.h <= 1.0 {
629 return;
630 }
631 let x0 = rect.x as f64;
632 let y0 = rect.y as f64;
633 let x1 = (rect.x + rect.w) as f64;
634 let y1 = (rect.y + rect.h) as f64;
635 let rr = RoundedRect::new(x0, y0, x1, y1, 4.0);
636 scene.push_layer(Fill::NonZero, Mix::Normal, 1.0, Affine::IDENTITY, &rr);
637 // Damero.
638 let cell = 8.0;
639 let light = Color::from_rgba8(200, 200, 200, 255);
640 let dark = Color::from_rgba8(150, 150, 150, 255);
641 let cols = ((x1 - x0) / cell).ceil() as i32;
642 let rows = ((y1 - y0) / cell).ceil() as i32;
643 for r in 0..rows {
644 for c in 0..cols {
645 let col = if (r + c) % 2 == 0 { light } else { dark };
646 let cxx = x0 + c as f64 * cell;
647 let cyy = y0 + r as f64 * cell;
648 let sq = KRect::new(cxx, cyy, (cxx + cell).min(x1), (cyy + cell).min(y1));
649 scene.fill(Fill::NonZero, Affine::IDENTITY, &col, None, &sq);
650 }
651 }
652 // Degradé del color: transparente → opaco.
653 let c0 = Color::from_rgba8(rgb[0], rgb[1], rgb[2], 0);
654 let c1 = Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255);
655 let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
656 .with_stops([c0, c1].as_slice());
657 scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
658 scene.pop_layer();
659 // Thumb.
660 let tx = x0 + (x1 - x0) * thumb_ratio as f64;
661 let thumb = RoundedRect::new(tx - 1.5, y0 - 1.0, tx + 1.5, y1 + 1.0, 1.5);
662 scene.fill(Fill::NonZero, Affine::IDENTITY, &marker_col, None, &thumb);
663 })
664}
665
666// ─────────────────────────────────────────────────────────────────────────
667// Chips (recientes / presets)
668// ─────────────────────────────────────────────────────────────────────────
669
670fn section_label<Msg: Clone + 'static>(text: &str, palette: &ColorPopupPalette) -> View<Msg> {
671 View::new(Style {
672 size: Size { width: percent(1.0_f32), height: length(14.0_f32) },
673 flex_shrink: 0.0,
674 ..Default::default()
675 })
676 .text_aligned(text.to_string(), 11.0, palette.fg_muted, Alignment::Start)
677}
678
679type ColorCb<Msg> = Arc<dyn Fn([u8; 4]) -> Msg + Send + Sync>;
680
681fn chip_row<Msg: Clone + Send + Sync + 'static>(
682 colors: Vec<[u8; 4]>,
683 cur: [u8; 4],
684 palette: &ColorPopupPalette,
685 on_change: &ColorCb<Msg>,
686) -> View<Msg> {
687 let cb: &(dyn Fn([u8; 4]) -> Msg + Send + Sync) = &**on_change;
688 let chips: Vec<View<Msg>> = colors
689 .into_iter()
690 .map(|c| {
691 let active = c[0] == cur[0] && c[1] == cur[1] && c[2] == cur[2] && c[3] == cur[3];
692 View::new(Style {
693 size: Size { width: length(22.0_f32), height: length(22.0_f32) },
694 flex_shrink: 0.0,
695 ..Default::default()
696 })
697 .fill(Color::from_rgba8(c[0], c[1], c[2], c[3]))
698 .radius(5.0)
699 .border(
700 if active { 2.0 } else { 1.0 },
701 if active { palette.accent } else { palette.border },
702 )
703 .on_click(cb(c))
704 })
705 .collect();
706 View::new(Style {
707 flex_direction: FlexDirection::Row,
708 flex_wrap: FlexWrap::Wrap,
709 size: Size { width: percent(1.0_f32), height: Dimension::auto() },
710 gap: Size { width: length(5.0_f32), height: length(5.0_f32) },
711 ..Default::default()
712 })
713 .children(chips)
714}
715
716// ─────────────────────────────────────────────────────────────────────────
717// Campos numéricos "scrubbables"
718// ─────────────────────────────────────────────────────────────────────────
719
720/// Un canal editable por arrastre vertical en [`scrub_field_view`].
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum ScrubChannel {
723 R,
724 G,
725 B,
726 A,
727 /// Tono 0..360.
728 H,
729 /// Saturación 0..100 (%).
730 S,
731 /// Valor/Brillo 0..100 (%).
732 V,
733}
734
735impl ScrubChannel {
736 fn label(self) -> &'static str {
737 match self {
738 ScrubChannel::R => "R",
739 ScrubChannel::G => "G",
740 ScrubChannel::B => "B",
741 ScrubChannel::A => "A",
742 ScrubChannel::H => "H",
743 ScrubChannel::S => "S",
744 ScrubChannel::V => "V",
745 }
746 }
747
748 /// Unidades de canal por px arrastrado (arriba = subir).
749 fn sensitivity(self) -> f32 {
750 match self {
751 ScrubChannel::R | ScrubChannel::G | ScrubChannel::B | ScrubChannel::A => 1.0,
752 ScrubChannel::H => 1.5,
753 ScrubChannel::S | ScrubChannel::V => 0.6,
754 }
755 }
756}
757
758/// Valor entero actual del canal para mostrar.
759pub fn scrub_channel_value(rgba: [u8; 4], ch: ScrubChannel) -> i32 {
760 let (h, s, v) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
761 match ch {
762 ScrubChannel::R => rgba[0] as i32,
763 ScrubChannel::G => rgba[1] as i32,
764 ScrubChannel::B => rgba[2] as i32,
765 ScrubChannel::A => rgba[3] as i32,
766 ScrubChannel::H => h.round() as i32,
767 ScrubChannel::S => (s * 100.0).round() as i32,
768 ScrubChannel::V => (v * 100.0).round() as i32,
769 }
770}
771
772/// Aplica un incremento `d` (en unidades del canal) al color y devuelve el nuevo
773/// RGBA. Público para testear la aritmética sin UI.
774pub fn apply_scrub(rgba: [u8; 4], ch: ScrubChannel, d: f32) -> [u8; 4] {
775 let mut out = rgba;
776 match ch {
777 ScrubChannel::R | ScrubChannel::G | ScrubChannel::B | ScrubChannel::A => {
778 let idx = match ch {
779 ScrubChannel::R => 0,
780 ScrubChannel::G => 1,
781 ScrubChannel::B => 2,
782 _ => 3,
783 };
784 out[idx] = (rgba[idx] as f32 + d).clamp(0.0, 255.0).round() as u8;
785 }
786 ScrubChannel::H | ScrubChannel::S | ScrubChannel::V => {
787 let (h, s, v) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
788 let (nh, ns, nv) = match ch {
789 ScrubChannel::H => ((h + d).rem_euclid(360.0), s, v),
790 ScrubChannel::S => (h, (s + d / 100.0).clamp(0.0, 1.0), v),
791 _ => (h, s, (v + d / 100.0).clamp(0.0, 1.0)),
792 };
793 let c = hsv_to_rgb(nh, ns, nv);
794 out = [c[0], c[1], c[2], rgba[3]];
795 }
796 }
797 out
798}
799
800/// Un campo numérico **scrubbable**: muestra `label valor` y responde al
801/// arrastre vertical (arriba = subir) sin robar el foco de teclado. Emite el
802/// color nuevo por `on_change`.
803pub fn scrub_field_view<Msg: Clone + Send + Sync + 'static>(
804 rgba: [u8; 4],
805 ch: ScrubChannel,
806 palette: &ColorPopupPalette,
807 on_change: Arc<dyn Fn([u8; 4]) -> Msg + Send + Sync>,
808) -> View<Msg> {
809 let value = scrub_channel_value(rgba, ch);
810 let sens = ch.sensitivity();
811 // El handler del drag queda congelado en el press: acumulamos el delta
812 // TOTAL desde el press y lo aplicamos a la base (color al iniciar), en vez
813 // de recomputar sobre un estado que no avanza entre eventos.
814 let base = rgba;
815 let acc: Arc<Mutex<f32>> = Arc::new(Mutex::new(0.0));
816 let handler = move |phase: DragPhase, _dx: f32, dy: f32| -> Option<Msg> {
817 match phase {
818 // Arrastre hacia arriba (dy negativo) = subir el valor.
819 DragPhase::Move => {
820 let mut a = acc.lock();
821 *a += -dy * sens;
822 Some(on_change(apply_scrub(base, ch, *a)))
823 }
824 DragPhase::End => {
825 *acc.lock() = 0.0;
826 None
827 }
828 }
829 };
830 View::new(Style {
831 flex_grow: 1.0,
832 flex_basis: percent(0.0_f32),
833 size: Size { width: Dimension::auto(), height: length(26.0_f32) },
834 align_items: Some(AlignItems::CENTER),
835 justify_content: Some(JustifyContent::CENTER),
836 ..Default::default()
837 })
838 .fill(palette.inset)
839 .radius(5.0)
840 .border(1.0, palette.border)
841 .draggable(handler)
842 .text(format!("{} {}", ch.label(), value), 12.0, palette.fg_text)
843}
844
845fn scrub_row<Msg: Clone + Send + Sync + 'static>(
846 rgba: [u8; 4],
847 channels: [ScrubChannel; 3],
848 palette: &ColorPopupPalette,
849 on_change: &ColorCb<Msg>,
850) -> View<Msg> {
851 let cells: Vec<View<Msg>> = channels
852 .iter()
853 .map(|ch| scrub_field_view(rgba, *ch, palette, on_change.clone()))
854 .collect();
855 View::new(Style {
856 flex_direction: FlexDirection::Row,
857 size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
858 gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
859 ..Default::default()
860 })
861 .children(cells)
862}
863
864// ─────────────────────────────────────────────────────────────────────────
865// Disparador (swatch que abre el popover)
866// ─────────────────────────────────────────────────────────────────────────
867
868/// El **swatch disparador**: una muestra del color actual (con damero de alfa
869/// implícito por el borde) que, al click, emite `on_open`. El caller abre el
870/// popover en `view_overlay`.
871pub fn color_trigger_view<Msg: Clone + 'static>(
872 rgba: [u8; 4],
873 palette: &ColorPopupPalette,
874 on_open: Msg,
875) -> View<Msg> {
876 View::new(Style {
877 size: Size { width: length(40.0_f32), height: length(24.0_f32) },
878 flex_shrink: 0.0,
879 ..Default::default()
880 })
881 .fill(Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]))
882 .radius(5.0)
883 .border(1.0, palette.border)
884 .on_click(on_open)
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 #[test]
892 fn recientes_dedup_y_cap() {
893 let mut r = Vec::new();
894 push_recent(&mut r, [1, 0, 0, 255], 4);
895 push_recent(&mut r, [2, 0, 0, 255], 4);
896 push_recent(&mut r, [1, 0, 0, 255], 4); // ya estaba → sube al frente
897 assert_eq!(r, vec![[1, 0, 0, 255], [2, 0, 0, 255]]);
898 for i in 0..10u8 {
899 push_recent(&mut r, [i, i, i, 255], 4);
900 }
901 assert_eq!(r.len(), 4);
902 }
903
904 #[test]
905 fn scrub_rgb_sube_y_clampa() {
906 assert_eq!(apply_scrub([100, 0, 0, 255], ScrubChannel::R, 20.0), [120, 0, 0, 255]);
907 assert_eq!(apply_scrub([250, 0, 0, 255], ScrubChannel::R, 20.0), [255, 0, 0, 255]);
908 assert_eq!(apply_scrub([5, 0, 0, 255], ScrubChannel::R, -20.0), [0, 0, 0, 255]);
909 }
910
911 #[test]
912 fn scrub_valor_hsv_oscurece() {
913 // V baja → color más oscuro conservando tono.
914 let c = apply_scrub([255, 0, 0, 255], ScrubChannel::V, -50.0);
915 assert!(c[0] < 255 && c[1] == 0 && c[2] == 0);
916 assert_eq!(c[3], 255);
917 }
918
919 #[test]
920 fn scrub_tono_gira_conservando_alfa() {
921 let c = apply_scrub([255, 0, 0, 128], ScrubChannel::H, 120.0);
922 // 0°+120° = verde puro.
923 assert_eq!(c, [0, 255, 0, 128]);
924 }
925
926 #[test]
927 fn scrub_channel_value_lee_hsv() {
928 assert_eq!(scrub_channel_value([255, 0, 0, 255], ScrubChannel::H), 0);
929 assert_eq!(scrub_channel_value([255, 0, 0, 255], ScrubChannel::S), 100);
930 assert_eq!(scrub_channel_value([255, 0, 0, 255], ScrubChannel::V), 100);
931 assert_eq!(scrub_channel_value([0, 0, 0, 200], ScrubChannel::A), 200);
932 }
933}