tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main widgets/color-picker/src/popup.rs Rust · 933 líneas · 37.8 KB Historia Quién tocó qué crudo

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

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}