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-switch` — toggle binario (track + thumb).
2//!
3//! Render-only: la app guarda el `bool` en su modelo y dispatcha el
4//! Msg de toggle al click. Visualmente:
5//! - Track horizontal (40×22 default) con color del estado activo.
6//! - Thumb circular (18px) que se posiciona a la izquierda (off) o
7//! derecha (on) del track.
8//!
9//! Para animar la transición, la app puede guardar un `Tween<f32>` con
10//! el progreso 0→1 y leerlo desde `view` para interpolar la posición
11//! del thumb. Sin tween la transición es instantánea — funcional pero
12//! menos elegante.
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::vec;
25use llimphi_compositor::llimphi_layout::taffy::{
26 prelude::{auto, length, Position, Size, Style},
27 Rect,
28};
29use llimphi_compositor::peniko::Color;
30use llimphi_compositor::View;
31use llimphi_theme::Theme;
32
33/// Paleta del switch.
34#[derive(Debug, Clone, Copy)]
35pub struct SwitchPalette {
36 pub track_off: Color,
37 pub track_on: Color,
38 pub thumb: Color,
39}
40
41impl SwitchPalette {
42 pub fn from_theme(t: &Theme) -> Self {
43 Self {
44 track_off: t.bg_button,
45 track_on: t.accent,
46 thumb: t.fg_text,
47 }
48 }
49}
50
51const TRACK_W: f32 = 40.0;
52const TRACK_H: f32 = 22.0;
53const THUMB_R: f32 = 9.0; // radio en px → diámetro 18
54const PAD: f32 = 2.0;
55
56/// Construye un switch. `progress` en `[0.0, 1.0]` indica la
57/// posición animada del thumb (0 = off, 1 = on). Para la transición
58/// instantánea usar `if state { 1.0 } else { 0.0 }`.
59///
60/// `on_toggle` se dispatcha al click; la app actualiza su `bool` y
61/// (opcionalmente) lanza un `Tween` que actualiza `progress` por frame.
62pub fn switch_view<Msg: Clone + 'static>(
63 progress: f32,
64 on_toggle: Msg,
65 palette: &SwitchPalette,
66) -> View<Msg> {
67 let p = progress.clamp(0.0, 1.0);
68
69 // Track color interpola entre off y on según progress.
70 let track_color = lerp_color(palette.track_off, palette.track_on, p);
71
72 // Thumb absolute dentro del track. Range del centro: PAD+THUMB_R a TRACK_W-PAD-THUMB_R.
73 let min_x = PAD;
74 let max_x = TRACK_W - PAD - THUMB_R * 2.0;
75 let thumb_x = min_x + (max_x - min_x) * p;
76 let thumb_y = (TRACK_H - THUMB_R * 2.0) * 0.5;
77
78 let thumb = View::new(Style {
79 position: Position::Absolute,
80 inset: Rect {
81 left: length(thumb_x),
82 top: length(thumb_y),
83 right: auto(),
84 bottom: auto(),
85 },
86 size: Size {
87 width: length(THUMB_R * 2.0),
88 height: length(THUMB_R * 2.0),
89 },
90 ..Default::default()
91 })
92 .fill(palette.thumb)
93 .radius(THUMB_R as f64)
94 .paint_with(move |scene, _ts, rect| {
95 // Highlight radial pequeño en cuadrante superior — el thumb se
96 // lee como esfera, no como círculo plano. Mismo patrón que el
97 // dot del badge (P6).
98 use llimphi_compositor::peniko::kurbo::{Affine, Circle};
99 use llimphi_compositor::peniko::Fill;
100 if rect.w <= 0.0 || rect.h <= 0.0 {
101 return;
102 }
103 let cx = (rect.x + rect.w * 0.5) as f64;
104 let cy = (rect.y + rect.h * 0.32) as f64;
105 let r = (rect.w as f64 * 0.18).max(1.0);
106 let highlight = Color::from_rgba8(255, 255, 255, 70);
107 scene.fill(
108 Fill::NonZero,
109 Affine::IDENTITY,
110 highlight,
111 None,
112 &Circle::new((cx, cy), r),
113 );
114 });
115
116 let track_radius = (TRACK_H * 0.5) as f64;
117 View::new(Style {
118 size: Size {
119 width: length(TRACK_W),
120 height: length(TRACK_H),
121 },
122 ..Default::default()
123 })
124 .fill(track_color)
125 .radius(track_radius)
126 .paint_with(move |scene, _ts, rect| {
127 // Gloss superior en el track — pill con luz cayendo desde arriba.
128 // El track interpola color (off/on) en el fill, el gloss queda
129 // estable encima en ambos estados.
130 use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect};
131 use llimphi_compositor::peniko::{Fill, Gradient};
132 if rect.w <= 0.0 || rect.h <= 0.0 {
133 return;
134 }
135 let x0 = rect.x as f64;
136 let y0 = rect.y as f64;
137 let x1 = (rect.x + rect.w) as f64;
138 let y1 = (rect.y + rect.h) as f64;
139 let y_mid = y0 + (y1 - y0) * 0.5;
140 let rr = RoundedRect::new(x0, y0, x1, y1, track_radius);
141 let top = Color::from_rgba8(255, 255, 255, 28);
142 let bot = Color::from_rgba8(255, 255, 255, 0);
143 let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
144 .with_stops([top, bot].as_slice());
145 scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
146 })
147 // Semántica: track switch (Checkbox con `pressed` para que el lector
148 // diga "interruptor on/off" en vez de "casilla marcada"). Sin un rol
149 // dedicado en accesskit; Button + pressed cubre la intención.
150 .role(llimphi_compositor::Role::Button)
151 .aria_pressed(p >= 0.5)
152 .on_click(on_toggle)
153 .children(vec![thumb])
154}
155
156fn lerp_color(a: Color, b: Color, t: f32) -> Color {
157 let [r0, g0, b0, a0] = a.components;
158 let [r1, g1, b1, a1] = b.components;
159 use llimphi_compositor::peniko::color::AlphaColor;
160 AlphaColor::new([
161 r0 + (r1 - r0) * t,
162 g0 + (g1 - g0) * t,
163 b0 + (b1 - b0) * t,
164 a0 + (a1 - a0) * t,
165 ])
166}