teksilo_widgets/color_picker/
hue_strip.rs1use std::borrow::Cow;
19use std::cell::Cell;
20use std::rc::Rc;
21use std::sync::LazyLock;
22
23use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
24use teksilo_core::accessibility::AccessNodeBuilder;
25use teksilo_core::accesskit::{Action, Role};
26use teksilo_core::build_context::BuildContext;
27use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
28use teksilo_core::focus::FocusOrigin;
29use teksilo_core::gesture::DragPhase;
30use teksilo_core::signal::Signal;
31use teksilo_core::widget::{
32 CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
33};
34use teksilo_core::widget_builder::HandlerSet;
35use teksilo_core::widget_id::WidgetId;
36use teksilo_tokens::{Color, CornerRadius, Orientation};
37
38const HUE_TEXTURE_NAME_HORIZONTAL: &str = "__teksilo_color_picker_hue_h_256";
44const HUE_TEXTURE_NAME_VERTICAL: &str = "__teksilo_color_picker_hue_v_256";
45const HUE_TEXTURE_LENGTH: u32 = 256;
46
47static HUE_PIXELS: LazyLock<Vec<u8>> = LazyLock::new(generate_hue_pixels);
52
53pub(crate) struct HueStrip {
54 hue: Signal<f32>,
55 set_hue: Rc<dyn Fn(f32)>,
56 dragging: Rc<Cell<bool>>,
57 cached_bounds: Rc<Cell<Rect>>,
58 focus_origin: Rc<Cell<Option<FocusOrigin>>>,
59 orientation: Orientation,
60 initial_enabled: bool,
62 label: String,
63}
64
65impl HueStrip {
66 pub(crate) fn new(
67 hue: Signal<f32>,
68 set_hue: Rc<dyn Fn(f32)>,
69 dragging: Rc<Cell<bool>>,
70 ) -> Self {
71 Self {
72 hue,
73 set_hue,
74 dragging,
75 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
76 focus_origin: Rc::new(Cell::new(None)),
77 orientation: Orientation::Vertical,
78 initial_enabled: true,
79 label: String::new(),
80 }
81 }
82
83 pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
84 self.orientation = orientation;
85 self
86 }
87
88 pub(crate) fn enabled(mut self, enabled: bool) -> Self {
90 self.initial_enabled = enabled;
91 self
92 }
93
94 pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
95 self.label = label.into();
96 self
97 }
98}
99
100impl std::fmt::Debug for HueStrip {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("HueStrip")
103 .field("orientation", &self.orientation)
104 .finish_non_exhaustive()
105 }
106}
107
108impl Widget for HueStrip {
109 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
110 let self_id = ctx.self_id();
111 if !self.initial_enabled {
113 ctx.enabled_when(self_id, false);
114 }
115 let registry = ctx.binding_registry();
116 self.hue.bind_to(
117 self_id,
118 registry,
119 teksilo_core::binding::BindingLevel::RepaintOnly,
120 );
121
122 let cached_bounds = self.cached_bounds.clone();
124 let dragging = self.dragging.clone();
125 let set_hue = self.set_hue.clone();
126 let orientation = self.orientation;
127
128 let apply: Rc<dyn Fn(f32, f32)> = {
129 let set_hue = set_hue.clone();
130 Rc::new(move |x: f32, y: f32| {
131 let bounds = cached_bounds.get();
132 let t = match orientation {
135 Orientation::Vertical => {
136 if bounds.height <= 0.0 {
137 return;
138 }
139 (y / bounds.height).clamp(0.0, 1.0)
140 }
141 Orientation::Horizontal => {
142 if bounds.width <= 0.0 {
143 return;
144 }
145 (x / bounds.width).clamp(0.0, 1.0)
146 }
147 };
148 let h = (t * 360.0).min(359.999);
151 (set_hue)(h);
152 })
153 };
154
155 let mut handlers = HandlerSet::new()
156 .focusable(true)
157 .cursor(CursorIcon::Pointer);
158
159 {
160 let dragging = dragging.clone();
161 let apply = apply.clone();
162 handlers = handlers.on_drag(move |phase, _ctx| match phase {
163 DragPhase::Started {
164 position,
165 button: PointerButton::Primary,
166 } => {
167 dragging.set(true);
168 apply(position.x, position.y);
169 }
170 DragPhase::Moved { position, .. } if dragging.get() => {
171 apply(position.x, position.y);
172 }
173 DragPhase::Ended { .. } => {
174 dragging.set(false);
175 }
176 _ => {}
177 });
178 }
179 {
180 let apply = apply.clone();
181 handlers = handlers.on_tap(move |event, _ctx| {
182 apply(event.position.x, event.position.y);
183 });
184 }
185
186 {
188 let set_hue = set_hue.clone();
189 let hue = self.hue.clone();
190 handlers = handlers.on_key(move |event, _ctx| {
191 let WidgetEvent::KeyDown { key, .. } = event else {
192 return EventResponse::Ignored;
193 };
194 match key {
195 Key::ArrowUp | Key::ArrowRight => {
196 let next = (hue.get() + 1.0).rem_euclid(360.0);
197 (set_hue)(next);
198 EventResponse::Handled
199 }
200 Key::ArrowDown | Key::ArrowLeft => {
201 let next = (hue.get() - 1.0).rem_euclid(360.0);
202 (set_hue)(next);
203 EventResponse::Handled
204 }
205 Key::PageUp => {
206 let next = (hue.get() + 15.0).rem_euclid(360.0);
207 (set_hue)(next);
208 EventResponse::Handled
209 }
210 Key::PageDown => {
211 let next = (hue.get() - 15.0).rem_euclid(360.0);
212 (set_hue)(next);
213 EventResponse::Handled
214 }
215 Key::Home => {
216 (set_hue)(0.0);
217 EventResponse::Handled
218 }
219 Key::End => {
220 (set_hue)(359.0);
221 EventResponse::Handled
222 }
223 _ => EventResponse::Ignored,
224 }
225 });
226 }
227
228 {
229 let focus_origin = self.focus_origin.clone();
230 handlers = handlers.on_focus(move |gained, _ctx| {
231 focus_origin.set(if gained {
232 Some(FocusOrigin::Keyboard)
233 } else {
234 None
235 });
236 });
237 }
238
239 {
241 let set_hue = set_hue.clone();
242 let hue = self.hue.clone();
243 handlers = handlers.on_access_action(move |action, _ctx| match action {
244 Action::Increment => {
245 (set_hue)((hue.get() + 1.0).rem_euclid(360.0));
246 EventResponse::Handled
247 }
248 Action::Decrement => {
249 (set_hue)((hue.get() - 1.0).rem_euclid(360.0));
250 EventResponse::Handled
251 }
252 _ => EventResponse::Ignored,
253 });
254 }
255
256 ctx.apply_self_handlers(handlers);
257 Vec::new()
258 }
259
260 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
261 use crate::styles::recipe_color_picker_style as cp;
262 let size = match self.orientation {
263 Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
264 Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
265 };
266 size.into()
267 }
268
269 fn place_children(
270 &self,
271 bounds: Rect,
272 _proposal: SizeProposal,
273 _children: &mut [WidgetPlacement],
274 _ctx: &LayoutContext,
275 ) {
276 self.cached_bounds.set(bounds);
277 }
278
279 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
280 use crate::styles::recipe_color_picker_style as cp;
281 self.cached_bounds.set(bounds);
282 let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
283
284 let (texture_name, tex_w, tex_h) = match self.orientation {
289 Orientation::Horizontal => (HUE_TEXTURE_NAME_HORIZONTAL, HUE_TEXTURE_LENGTH, 1),
290 Orientation::Vertical => (HUE_TEXTURE_NAME_VERTICAL, 1, HUE_TEXTURE_LENGTH),
291 };
292 canvas.ensure_image_registered(
293 texture_name,
294 tex_w,
295 tex_h,
296 Cow::Borrowed(HUE_PIXELS.as_slice()),
297 );
298
299 canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
303 canvas.draw_image(bounds, texture_name);
304
305 canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
307
308 let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
310 let thumb_w = cp::STRIP_THUMB_WIDTH;
311 let thumb_h = cp::STRIP_THUMB_HEIGHT;
312 let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
313 let (cx, cy) = match self.orientation {
314 Orientation::Vertical => (bounds.x + bounds.width * 0.5, bounds.y + bounds.height * t),
315 Orientation::Horizontal => {
316 (bounds.x + bounds.width * t, bounds.y + bounds.height * 0.5)
317 }
318 };
319 let thumb_rect = match self.orientation {
320 Orientation::Vertical => Rect::new(
321 bounds.x - 2.0,
322 cy - thumb_h * 0.5,
323 bounds.width + 4.0,
324 thumb_h,
325 ),
326 Orientation::Horizontal => Rect::new(
327 cx - thumb_w * 0.5,
328 bounds.y - 2.0,
329 thumb_w,
330 bounds.height + 4.0,
331 ),
332 };
333 canvas.fill_rounded_rect(thumb_rect, thumb_radius, Color::WHITE);
334 canvas.stroke_rounded_rect(
335 thumb_rect,
336 thumb_radius,
337 Color::new(0.0, 0.0, 0.0, 0.5),
338 1.0,
339 );
340 }
341
342 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
343 builder.set_role(Role::Slider);
344 if !self.label.is_empty() {
345 builder.set_name(&self.label);
346 }
347 builder.set_numeric_value(self.hue.get() as f64);
348 builder.set_min_numeric_value(0.0);
349 builder.set_max_numeric_value(360.0);
350 builder.set_numeric_value_step(1.0);
351 builder.set_numeric_value_jump(15.0);
352 let orientation = match self.orientation {
353 Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
354 Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
355 };
356 builder.set_orientation(orientation);
357 builder.add_action(Action::Increment);
359 builder.add_action(Action::Decrement);
360 builder.add_action(Action::Focus);
361 }
362}
363
364fn generate_hue_pixels() -> Vec<u8> {
367 let mut pixels = Vec::with_capacity(HUE_TEXTURE_LENGTH as usize * 4);
368 for i in 0..HUE_TEXTURE_LENGTH {
369 let h = (i as f32 / HUE_TEXTURE_LENGTH as f32) * 360.0;
370 let c = Color::from_hsv(h, 1.0, 1.0);
371 pixels.push((c.r() * 255.0) as u8);
372 pixels.push((c.g() * 255.0) as u8);
373 pixels.push((c.b() * 255.0) as u8);
374 pixels.push(255);
375 }
376 pixels
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn hue_pixels_are_rainbow() {
385 let pixels = generate_hue_pixels();
386 assert_eq!(pixels.len(), HUE_TEXTURE_LENGTH as usize * 4);
387 assert_eq!(pixels[0], 255);
389 assert_eq!(pixels[1], 0);
390 assert_eq!(pixels[2], 0);
391 let mid = (HUE_TEXTURE_LENGTH as usize / 2) * 4;
393 assert!(pixels[mid] < 50);
394 assert!(pixels[mid + 1] > 200);
395 assert!(pixels[mid + 2] > 200);
396 }
397}