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, PointerButton, WidgetEvent};
28use teksilo_core::focus::FocusOrigin;
29use teksilo_core::gesture::DragPhase;
30use teksilo_core::pointer::touch_action::TouchAction;
31use teksilo_core::signal::Signal;
32use teksilo_core::widget::{
33 CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
34};
35use teksilo_core::widget_builder::HandlerSet;
36use teksilo_core::widget_id::WidgetId;
37use teksilo_tokens::{Color, CornerRadius, Orientation};
38
39use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
40
41const HUE_TEXTURE_NAME_HORIZONTAL: &str = "__teksilo_color_picker_hue_h_256";
47const HUE_TEXTURE_NAME_VERTICAL: &str = "__teksilo_color_picker_hue_v_256";
48const HUE_TEXTURE_LENGTH: u32 = 256;
49
50static HUE_PIXELS: LazyLock<Vec<u8>> = LazyLock::new(generate_hue_pixels);
55
56static HUE_PIXELS_VERTICAL: LazyLock<Vec<u8>> = LazyLock::new(|| {
64 HUE_PIXELS
65 .as_chunks::<4>()
66 .0
67 .iter()
68 .rev()
69 .flat_map(|px| px.iter().copied())
70 .collect()
71});
72
73pub(crate) struct HueStrip {
74 hue: Signal<f32>,
75 set_hue: Rc<dyn Fn(f32)>,
76 dragging: Rc<Cell<bool>>,
77 cached_bounds: Rc<Cell<Rect>>,
78 focus_origin: Rc<Cell<Option<FocusOrigin>>>,
79 orientation: Orientation,
80 initial_enabled: bool,
82 label: String,
83}
84
85impl HueStrip {
86 pub(crate) fn new(
87 hue: Signal<f32>,
88 set_hue: Rc<dyn Fn(f32)>,
89 dragging: Rc<Cell<bool>>,
90 ) -> Self {
91 Self {
92 hue,
93 set_hue,
94 dragging,
95 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
96 focus_origin: Rc::new(Cell::new(None)),
97 orientation: Orientation::Vertical,
98 initial_enabled: true,
99 label: String::new(),
100 }
101 }
102
103 pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
104 self.orientation = orientation;
105 self
106 }
107
108 pub(crate) fn enabled(mut self, enabled: bool) -> Self {
110 self.initial_enabled = enabled;
111 self
112 }
113
114 pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
115 self.label = label.into();
116 self
117 }
118}
119
120impl std::fmt::Debug for HueStrip {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("HueStrip")
123 .field("orientation", &self.orientation)
124 .finish_non_exhaustive()
125 }
126}
127
128impl Widget for HueStrip {
129 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
130 let self_id = ctx.self_id();
131 if !self.initial_enabled {
133 ctx.enabled_when(self_id, false);
134 }
135 let registry = ctx.binding_registry();
136 self.hue.bind_to(
137 self_id,
138 registry,
139 teksilo_core::binding::BindingLevel::RepaintOnly,
140 );
141
142 let cached_bounds = self.cached_bounds.clone();
144 let dragging = self.dragging.clone();
145 let set_hue = self.set_hue.clone();
146 let orientation = self.orientation;
147
148 let apply: Rc<dyn Fn(f32, f32)> = {
149 let set_hue = set_hue.clone();
150 Rc::new(move |x: f32, y: f32| {
151 let bounds = cached_bounds.get();
152 let t = match orientation {
155 Orientation::Vertical => {
160 if bounds.height <= 0.0 {
161 return;
162 }
163 1.0 - (y / bounds.height).clamp(0.0, 1.0)
164 }
165 Orientation::Horizontal => {
166 if bounds.width <= 0.0 {
167 return;
168 }
169 (x / bounds.width).clamp(0.0, 1.0)
170 }
171 };
172 let h = (t * 360.0).min(359.999);
175 (set_hue)(h);
176 })
177 };
178
179 let mut handlers = HandlerSet::new()
180 .focusable(true)
181 .cursor(CursorIcon::Pointer)
182 .touch_action(TouchAction::NONE);
191
192 {
193 let dragging = dragging.clone();
194 let apply = apply.clone();
195 handlers = handlers.on_drag(move |phase, _ctx| match phase {
196 DragPhase::Started {
197 position,
198 button: PointerButton::Primary,
199 ..
200 } => {
201 dragging.set(true);
202 apply(position.x, position.y);
203 }
204 DragPhase::Moved { position, .. } if dragging.get() => {
205 apply(position.x, position.y);
206 }
207 DragPhase::Ended { .. } => {
208 dragging.set(false);
209 }
210 _ => {}
211 });
212 }
213 {
214 let apply = apply.clone();
215 handlers = handlers.on_tap(move |event, _ctx| {
216 apply(event.position.x, event.position.y);
217 });
218 }
219
220 {
222 let set_hue = set_hue.clone();
223 let hue = self.hue.clone();
224 handlers = handlers.on_key(move |event, _ctx| {
225 let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
226 return EventResponse::Ignored;
227 };
228 let Some(mv) = range_nav::range_move(
232 *key,
233 *modifiers,
234 RangeKind::Scalar,
235 RangeAxis::Both,
236 false,
237 ) else {
238 return EventResponse::Ignored;
239 };
240 let next = match mv {
245 RangeMove::Step { increase } => {
246 (hue.get() + if increase { 1.0 } else { -1.0 }).rem_euclid(360.0)
247 }
248 RangeMove::Page { increase } => {
249 (hue.get() + if increase { 15.0 } else { -15.0 }).rem_euclid(360.0)
250 }
251 RangeMove::ToMin => 0.0,
252 RangeMove::ToMax => 359.0,
253 };
254 (set_hue)(next);
255 EventResponse::Handled
256 });
257 }
258
259 {
260 let focus_origin = self.focus_origin.clone();
261 handlers = handlers.on_focus(move |gained, _ctx| {
262 focus_origin.set(if gained {
263 Some(FocusOrigin::Keyboard)
264 } else {
265 None
266 });
267 });
268 }
269
270 {
272 let set_hue = set_hue.clone();
273 let hue = self.hue.clone();
274 handlers = handlers.on_access_action(move |action, _ctx| match action {
275 Action::Increment => {
276 (set_hue)((hue.get() + 1.0).rem_euclid(360.0));
277 EventResponse::Handled
278 }
279 Action::Decrement => {
280 (set_hue)((hue.get() - 1.0).rem_euclid(360.0));
281 EventResponse::Handled
282 }
283 _ => EventResponse::Ignored,
284 });
285 }
286
287 ctx.apply_self_handlers(handlers);
288 Vec::new()
289 }
290
291 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
292 use crate::styles::recipe_color_picker_style as cp;
293 let size = match self.orientation {
294 Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
295 Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
296 };
297 size.into()
298 }
299
300 fn place_children(
301 &self,
302 bounds: Rect,
303 _proposal: SizeProposal,
304 _children: &mut [WidgetPlacement],
305 _ctx: &LayoutContext,
306 ) {
307 self.cached_bounds.set(bounds);
308 }
309
310 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
311 use crate::styles::recipe_color_picker_style as cp;
312 self.cached_bounds.set(bounds);
313 let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
314
315 let (texture_name, tex_w, tex_h, pixels) = match self.orientation {
320 Orientation::Horizontal => (
321 HUE_TEXTURE_NAME_HORIZONTAL,
322 HUE_TEXTURE_LENGTH,
323 1,
324 HUE_PIXELS.as_slice(),
325 ),
326 Orientation::Vertical => (
327 HUE_TEXTURE_NAME_VERTICAL,
328 1,
329 HUE_TEXTURE_LENGTH,
330 HUE_PIXELS_VERTICAL.as_slice(),
331 ),
332 };
333 canvas.ensure_image_registered(texture_name, tex_w, tex_h, Cow::Borrowed(pixels));
334
335 canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
339 canvas.draw_image(bounds, texture_name);
340
341 canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
343
344 let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
346 let thumb_w = cp::STRIP_THUMB_WIDTH;
347 let thumb_h = cp::STRIP_THUMB_HEIGHT;
348 let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
349 let (cx, cy) = match self.orientation {
350 Orientation::Vertical => (
352 bounds.x + bounds.width * 0.5,
353 bounds.bottom() - bounds.height * t,
354 ),
355 Orientation::Horizontal => {
356 (bounds.x + bounds.width * t, bounds.y + bounds.height * 0.5)
357 }
358 };
359 let thumb_rect = match self.orientation {
360 Orientation::Vertical => Rect::new(
361 bounds.x - 2.0,
362 cy - thumb_h * 0.5,
363 bounds.width + 4.0,
364 thumb_h,
365 ),
366 Orientation::Horizontal => Rect::new(
367 cx - thumb_w * 0.5,
368 bounds.y - 2.0,
369 thumb_w,
370 bounds.height + 4.0,
371 ),
372 };
373 canvas.fill_rounded_rect(thumb_rect, thumb_radius, Color::WHITE);
374 canvas.stroke_rounded_rect(
375 thumb_rect,
376 thumb_radius,
377 Color::new(0.0, 0.0, 0.0, 0.5),
378 1.0,
379 );
380 }
381
382 fn hit_outset(
389 &self,
390 kind: teksilo_tokens::PointerKind,
391 tokens: &teksilo_tokens::InputTokens,
392 ) -> teksilo_canvas::EdgeInsets {
393 crate::button::target_outset(self.cached_bounds.get().size(), kind, tokens)
394 }
395
396 fn target_regions(&self, bounds: Rect) -> Vec<teksilo_core::partition::TargetRegion> {
402 use crate::styles::recipe_color_picker_style as cp;
403 use teksilo_core::partition::TargetRegion;
404
405 let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
406 let thumb = match self.orientation {
407 Orientation::Vertical => Rect::new(
408 bounds.x - 2.0,
409 bounds.y + bounds.height * t - cp::STRIP_THUMB_HEIGHT * 0.5,
410 bounds.width + 4.0,
411 cp::STRIP_THUMB_HEIGHT,
412 ),
413 Orientation::Horizontal => Rect::new(
414 bounds.x + bounds.width * t - cp::STRIP_THUMB_WIDTH * 0.5,
415 bounds.y - 2.0,
416 cp::STRIP_THUMB_WIDTH,
417 bounds.height + 4.0,
418 ),
419 };
420 vec![
421 TargetRegion::target(bounds, 0),
422 TargetRegion::grab(thumb, 1),
423 ]
424 }
425
426 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
427 builder.set_role(Role::Slider);
428 if !self.label.is_empty() {
429 builder.set_name(&self.label);
430 }
431 builder.set_numeric_value(self.hue.get() as f64);
432 builder.set_min_numeric_value(0.0);
433 builder.set_max_numeric_value(360.0);
434 builder.set_numeric_value_step(1.0);
435 builder.set_numeric_value_jump(15.0);
436 let orientation = match self.orientation {
437 Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
438 Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
439 };
440 builder.set_orientation(orientation);
441 builder.add_action(Action::Increment);
443 builder.add_action(Action::Decrement);
444 builder.add_action(Action::Focus);
445 }
446}
447
448fn generate_hue_pixels() -> Vec<u8> {
451 let mut pixels = Vec::with_capacity(HUE_TEXTURE_LENGTH as usize * 4);
452 for i in 0..HUE_TEXTURE_LENGTH {
453 let h = (i as f32 / HUE_TEXTURE_LENGTH as f32) * 360.0;
454 let c = Color::from_hsv(h, 1.0, 1.0);
455 pixels.push((c.r() * 255.0) as u8);
456 pixels.push((c.g() * 255.0) as u8);
457 pixels.push((c.b() * 255.0) as u8);
458 pixels.push(255);
459 }
460 pixels
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn hue_pixels_are_rainbow() {
469 let pixels = generate_hue_pixels();
470 assert_eq!(pixels.len(), HUE_TEXTURE_LENGTH as usize * 4);
471 assert_eq!(pixels[0], 255);
473 assert_eq!(pixels[1], 0);
474 assert_eq!(pixels[2], 0);
475 let mid = (HUE_TEXTURE_LENGTH as usize / 2) * 4;
477 assert!(pixels[mid] < 50);
478 assert!(pixels[mid + 1] > 200);
479 assert!(pixels[mid + 2] > 200);
480 }
481}