1use crate::components::config_provider::use_config;
2use crate::components::form::use_form_item_control;
3#[cfg(target_arch = "wasm32")]
4use crate::components::interaction::as_pointer_event;
5use dioxus::events::PointerData;
6use dioxus::prelude::*;
7use serde_json::Value;
8use std::rc::Rc;
9#[cfg(target_arch = "wasm32")]
10use wasm_bindgen::JsCast;
11
12#[derive(Clone, Copy, Debug, PartialEq)]
14struct Hsva {
15 h: f64, s: f64, v: f64, a: f64, }
20
21#[derive(Props, Clone, PartialEq)]
23pub struct ColorPickerProps {
24 #[props(optional)]
26 pub value: Option<String>,
27 #[props(optional)]
29 pub default_value: Option<String>,
30 #[props(default)]
31 pub disabled: bool,
32 #[props(default)]
33 pub allow_clear: bool,
34 #[props(optional)]
35 pub class: Option<String>,
36 #[props(optional)]
37 pub style: Option<String>,
38 #[props(optional)]
40 pub on_change: Option<EventHandler<String>>,
41 #[props(optional)]
43 pub on_change_complete: Option<EventHandler<String>>,
44}
45
46#[component]
47pub fn ColorPicker(props: ColorPickerProps) -> Element {
48 let ColorPickerProps {
49 value,
50 default_value,
51 disabled,
52 allow_clear,
53 class,
54 style,
55 on_change,
56 on_change_complete,
57 } = props;
58
59 let config = use_config();
60 let form_control = use_form_item_control();
61 let controlled = value.is_some();
62
63 let initial = resolve_color(value.clone(), &form_control, default_value.as_ref());
64 let color_state = use_signal(|| initial);
65 let mut text_value = use_signal(|| color_to_hex(initial.as_ref()));
66
67 {
69 let form_ctx = form_control.clone();
70 let prop_val = value.clone();
71 let mut color_signal = color_state.clone();
72 let mut text_signal = text_value.clone();
73 use_effect(move || {
74 let next = resolve_color(prop_val.clone(), &form_ctx, None);
75 color_signal.set(next);
76 text_signal.set(color_to_hex(next.as_ref()));
77 });
78 }
79
80 let is_disabled =
81 disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
82
83 let mut class_list = vec!["adui-color-picker".to_string()];
84 if is_disabled {
85 class_list.push("adui-color-picker-disabled".into());
86 }
87 if let Some(extra) = class {
88 class_list.push(extra);
89 }
90 let class_attr = class_list.join(" ");
91 let style_attr = style.unwrap_or_default();
92
93 let apply_color: Rc<dyn Fn(Option<Hsva>, bool)> = Rc::new({
94 let on_change_cb = on_change.clone();
95 let on_change_complete_cb = on_change_complete.clone();
96 let form_ctx_for_apply = form_control.clone();
97 let color_for_apply = color_state.clone();
98 let text_for_apply = text_value.clone();
99 move |next: Option<Hsva>, fire_change: bool| {
100 if !controlled {
101 let mut state = color_for_apply;
102 state.set(next);
103 }
104 let hex = color_to_hex(next.as_ref());
105 let mut text_state = text_for_apply;
106 text_state.set(hex.clone());
107
108 if let Some(ctx) = form_ctx_for_apply.as_ref() {
109 if hex.is_empty() {
110 ctx.set_value(Value::Null);
111 } else {
112 ctx.set_value(Value::String(hex.clone()));
113 }
114 }
115
116 if fire_change {
117 if let Some(cb) = on_change_cb.as_ref() {
118 cb.call(hex.clone());
119 }
120 }
121 if let Some(cb) = on_change_complete_cb.as_ref() {
122 cb.call(hex);
123 }
124 }
125 });
126
127 let apply_for_input = apply_color.clone();
128 let apply_for_clear = apply_color.clone();
129
130 let dragging_sat = use_signal(|| false);
132 let dragging_hue = use_signal(|| false);
133 let dragging_alpha = use_signal(|| false);
134
135 let current = color_state.read().clone();
137 let base_hue = current.map(|c| c.h).unwrap_or(0.0);
138 let hue_rgb = hsv_to_rgb(base_hue, 1.0, 1.0);
139 let sat_cursor = current
140 .map(|c| (c.s.clamp(0.0, 1.0), 1.0 - c.v.clamp(0.0, 1.0)))
141 .unwrap_or((0.0, 0.0));
142 let (sat_x, sat_y) = sat_cursor;
143 let _alpha_value = current.map(|c| c.a).unwrap_or(1.0);
144 let preview_css = color_to_css(current.as_ref());
145 let hue_gradient = "linear-gradient(90deg, red 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, red 100%)";
146 let alpha_gradient = format!(
147 "linear-gradient(90deg, rgba({},{},{},0) 0%, rgba({},{},{},1) 100%)",
148 hue_rgb.0, hue_rgb.1, hue_rgb.2, hue_rgb.0, hue_rgb.1, hue_rgb.2
149 );
150
151 let handle_sat_pointer = {
152 let apply_for_sat = apply_color.clone();
153 let color_signal = color_state.clone();
154 let mut is_dragging = dragging_sat.clone();
155 move |evt: Event<PointerData>| {
156 if is_disabled {
157 return;
158 }
159
160 let buttons = evt.held_buttons();
162 if !buttons.contains(dioxus::html::input_data::MouseButton::Primary) {
163 is_dragging.set(false);
164 return;
165 }
166
167 is_dragging.set(true);
168
169 let elem_coords = evt.element_coordinates();
171 let s = (elem_coords.x / 200.0).clamp(0.0, 1.0);
172 let v = 1.0 - (elem_coords.y / 150.0).clamp(0.0, 1.0);
173
174 let current_color = color_signal.read().clone();
175 let current_alpha = current_color.map(|c| c.a).unwrap_or(1.0);
176 let mut next = current_color.unwrap_or(Hsva {
177 h: base_hue,
178 s: 1.0,
179 v: 1.0,
180 a: current_alpha,
181 });
182 next.s = s;
183 next.v = v;
184 apply_for_sat(Some(next), true);
185 }
186 };
187
188 let handle_hue_pointer = {
189 let apply_for_hue = apply_color.clone();
190 let color_signal = color_state.clone();
191 let mut is_dragging = dragging_hue.clone();
192 move |evt: Event<PointerData>| {
193 if is_disabled {
194 return;
195 }
196
197 let buttons = evt.held_buttons();
198 if !buttons.contains(dioxus::html::input_data::MouseButton::Primary) {
199 is_dragging.set(false);
200 return;
201 }
202
203 is_dragging.set(true);
204
205 let elem_coords = evt.element_coordinates();
206 let ratio = (elem_coords.x / 200.0).clamp(0.0, 1.0);
207 let h = ratio * 360.0;
208
209 let current_color = color_signal.read().clone();
210 let current_alpha = current_color.map(|c| c.a).unwrap_or(1.0);
211 let mut next = current_color.unwrap_or(Hsva {
212 h,
213 s: 1.0,
214 v: 1.0,
215 a: current_alpha,
216 });
217 next.h = h;
218 apply_for_hue(Some(next), true);
219 }
220 };
221
222 let handle_alpha_pointer = {
223 let apply_for_alpha = apply_color.clone();
224 let color_signal = color_state.clone();
225 let mut is_dragging = dragging_alpha.clone();
226 move |evt: Event<PointerData>| {
227 if is_disabled {
228 return;
229 }
230
231 let buttons = evt.held_buttons();
232 if !buttons.contains(dioxus::html::input_data::MouseButton::Primary) {
233 is_dragging.set(false);
234 return;
235 }
236
237 is_dragging.set(true);
238
239 let elem_coords = evt.element_coordinates();
240 let ratio = (elem_coords.x / 200.0).clamp(0.0, 1.0);
241
242 let current_color = color_signal.read().clone();
243 let mut next = current_color.unwrap_or(Hsva {
244 h: base_hue,
245 s: 1.0,
246 v: 1.0,
247 a: ratio,
248 });
249 next.a = ratio;
250 apply_for_alpha(Some(next), true);
251 }
252 };
253
254 let handle_input = move |evt: Event<FormData>| {
255 if is_disabled {
256 return;
257 }
258 let text = evt.value();
259 text_value.set(text.clone());
260 let parsed = parse_color(&text);
261 apply_for_input(parsed, true);
262 };
263
264 let handle_clear = move |_| {
265 if is_disabled || !allow_clear {
266 return;
267 }
268 apply_for_clear(None, true);
269 };
270
271 rsx! {
272 div { class: "{class_attr}", style: "{style_attr}",
273 div { class: "adui-color-picker-preview",
274 style: "background:{preview_css};",
275 }
276 div { class: "adui-color-picker-controls",
277 div { class: "adui-color-picker-sat",
278 style: "background: {hue_background(base_hue)};",
279 onpointerdown: handle_sat_pointer.clone(),
280 onpointermove: handle_sat_pointer,
281 div { class: "adui-color-picker-sat-white" }
282 div { class: "adui-color-picker-sat-black" }
283 div { class: "adui-color-picker-sat-handle",
284 style: format!("left:{:.2}%;top:{:.2}%;", sat_x * 100.0, sat_y * 100.0),
285 }
286 }
287 div { class: "adui-color-picker-slider",
288 style: "background:{hue_gradient};",
289 onpointerdown: handle_hue_pointer.clone(),
290 onpointermove: handle_hue_pointer,
291 }
292 div { class: "adui-color-picker-slider",
293 style: format!("background:{alpha_gradient};"),
294 onpointerdown: handle_alpha_pointer.clone(),
295 onpointermove: handle_alpha_pointer,
296 }
297 div { class: "adui-color-picker-input-row",
298 input {
299 class: "adui-color-picker-input",
300 value: "{text_value.read()}",
301 disabled: is_disabled,
302 oninput: handle_input,
303 }
304 if allow_clear {
305 button { class: "adui-color-picker-clear", disabled: is_disabled, onclick: handle_clear, "Clear" }
306 }
307 }
308 }
309 }
310 }
311}
312
313fn resolve_color(
314 value: Option<String>,
315 form_control: &Option<crate::components::form::FormItemControlContext>,
316 fallback: Option<&String>,
317) -> Option<Hsva> {
318 value
319 .or_else(|| {
320 form_control
321 .as_ref()
322 .and_then(|ctx| value_from_form(ctx.value()))
323 })
324 .or_else(|| fallback.cloned())
325 .and_then(|s| parse_color(&s))
326}
327
328fn value_from_form(val: Option<Value>) -> Option<String> {
329 match val {
330 Some(Value::String(s)) => Some(s),
331 _ => None,
332 }
333}
334
335fn parse_color(input: &str) -> Option<Hsva> {
336 let trimmed = input.trim();
337 if trimmed.is_empty() {
338 return None;
339 }
340 if !trimmed.starts_with('#') {
341 return None;
342 }
343 let hex = trimmed.trim_start_matches('#');
344 let (r, g, b, a) = match hex.len() {
345 6 => {
346 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
347 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
348 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
349 (r, g, b, 255)
350 }
351 8 => {
352 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
353 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
354 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
355 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
356 (r, g, b, a)
357 }
358 _ => return None,
359 };
360 let (h, s, v) = rgb_to_hsv(r, g, b);
361 Some(Hsva {
362 h,
363 s,
364 v,
365 a: (a as f64 / 255.0).clamp(0.0, 1.0),
366 })
367}
368
369fn color_to_hex(hsva: Option<&Hsva>) -> String {
370 if let Some(color) = hsva {
371 let (r, g, b) = hsv_to_rgb(color.h, color.s, color.v);
372 if (color.a - 1.0).abs() < f64::EPSILON {
373 format!("#{:02X}{:02X}{:02X}", r, g, b)
374 } else {
375 let a = (color.a * 255.0).round() as u8;
376 format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
377 }
378 } else {
379 String::new()
380 }
381}
382
383fn color_to_css(hsva: Option<&Hsva>) -> String {
384 if let Some(color) = hsva {
385 let (r, g, b) = hsv_to_rgb(color.h, color.s, color.v);
386 format!("rgba({},{},{},{:.3})", r, g, b, color.a)
387 } else {
388 "transparent".into()
389 }
390}
391
392fn hue_background(h: f64) -> String {
393 let (r, g, b) = hsv_to_rgb(h, 1.0, 1.0);
394 format!("rgb({},{},{})", r, g, b)
395}
396
397fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
398 let c = v * s;
399 let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
400 let m = v - c;
401 let (r1, g1, b1) = match (h / 60.0).floor() as i32 {
402 0 => (c, x, 0.0),
403 1 => (x, c, 0.0),
404 2 => (0.0, c, x),
405 3 => (0.0, x, c),
406 4 => (x, 0.0, c),
407 _ => (c, 0.0, x),
408 };
409 (
410 ((r1 + m) * 255.0).round() as u8,
411 ((g1 + m) * 255.0).round() as u8,
412 ((b1 + m) * 255.0).round() as u8,
413 )
414}
415
416fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
417 let r = r as f64 / 255.0;
418 let g = g as f64 / 255.0;
419 let b = b as f64 / 255.0;
420 let max = r.max(g).max(b);
421 let min = r.min(g).min(b);
422 let delta = max - min;
423
424 let h = if delta < f64::EPSILON {
425 0.0
426 } else if (max - r).abs() < f64::EPSILON {
427 60.0 * (((g - b) / delta) % 6.0)
428 } else if (max - g).abs() < f64::EPSILON {
429 60.0 * (((b - r) / delta) + 2.0)
430 } else {
431 60.0 * (((r - g) / delta) + 4.0)
432 };
433 let s = if max.abs() < f64::EPSILON {
434 0.0
435 } else {
436 delta / max
437 };
438 (if h < 0.0 { h + 360.0 } else { h }, s, max)
439}
440
441#[cfg(test)]
442mod color_picker_tests {
443 use super::*;
444
445 #[test]
446 fn parse_color_empty_string() {
447 assert_eq!(parse_color(""), None);
448 assert_eq!(parse_color(" "), None);
449 }
450
451 #[test]
452 fn parse_color_invalid_format() {
453 assert_eq!(parse_color("not-a-color"), None);
454 assert_eq!(parse_color("rgb(255,0,0)"), None);
455 assert_eq!(parse_color("#GGG"), None);
456 }
457
458 #[test]
459 fn parse_color_6_digit_hex() {
460 let result = parse_color("#FF0000");
461 assert!(result.is_some());
462 let color = result.unwrap();
463 assert!((color.h - 0.0).abs() < 1.0 || (color.h - 360.0).abs() < 1.0);
464 assert_eq!(color.a, 1.0);
465 }
466
467 #[test]
468 fn parse_color_8_digit_hex() {
469 let result = parse_color("#FF000080");
470 assert!(result.is_some());
471 let color = result.unwrap();
472 assert!((color.a - 0.5).abs() < 0.01);
473 }
474
475 #[test]
476 fn color_to_hex_with_alpha() {
477 let color = Hsva {
478 h: 0.0,
479 s: 1.0,
480 v: 1.0,
481 a: 0.5,
482 };
483 let hex = color_to_hex(Some(&color));
484 assert!(hex.starts_with('#'));
485 assert_eq!(hex.len(), 9); }
487
488 #[test]
489 fn color_to_hex_without_alpha() {
490 let color = Hsva {
491 h: 0.0,
492 s: 1.0,
493 v: 1.0,
494 a: 1.0,
495 };
496 let hex = color_to_hex(Some(&color));
497 assert_eq!(hex.len(), 7); }
499
500 #[test]
501 fn color_to_hex_none() {
502 assert_eq!(color_to_hex(None), "");
503 }
504
505 #[test]
506 fn color_to_css_with_color() {
507 let color = Hsva {
508 h: 0.0,
509 s: 1.0,
510 v: 1.0,
511 a: 0.5,
512 };
513 let css = color_to_css(Some(&color));
514 assert!(css.starts_with("rgba("));
515 }
516
517 #[test]
518 fn color_to_css_none() {
519 assert_eq!(color_to_css(None), "transparent");
520 }
521
522 #[test]
523 fn hsv_to_rgb_red() {
524 let (r, g, b) = hsv_to_rgb(0.0, 1.0, 1.0);
525 assert_eq!(r, 255);
526 assert_eq!(g, 0);
527 assert_eq!(b, 0);
528 }
529
530 #[test]
531 fn hsv_to_rgb_green() {
532 let (r, g, b) = hsv_to_rgb(120.0, 1.0, 1.0);
533 assert_eq!(r, 0);
534 assert_eq!(g, 255);
535 assert_eq!(b, 0);
536 }
537
538 #[test]
539 fn hsv_to_rgb_blue() {
540 let (r, g, b) = hsv_to_rgb(240.0, 1.0, 1.0);
541 assert_eq!(r, 0);
542 assert_eq!(g, 0);
543 assert_eq!(b, 255);
544 }
545
546 #[test]
547 fn rgb_to_hsv_red() {
548 let (h, s, v) = rgb_to_hsv(255, 0, 0);
549 assert!((h - 0.0).abs() < 1.0 || (h - 360.0).abs() < 1.0);
550 assert!((s - 1.0).abs() < 0.01);
551 assert!((v - 1.0).abs() < 0.01);
552 }
553
554 #[test]
555 fn rgb_to_hsv_green() {
556 let (h, s, v) = rgb_to_hsv(0, 255, 0);
557 assert!((h - 120.0).abs() < 1.0);
558 assert!((s - 1.0).abs() < 0.01);
559 assert!((v - 1.0).abs() < 0.01);
560 }
561
562 #[test]
563 fn rgb_to_hsv_black() {
564 let (_h, _s, v) = rgb_to_hsv(0, 0, 0);
565 assert!((v - 0.0).abs() < 0.01);
566 }
567
568 #[test]
569 fn rgb_to_hsv_white() {
570 let (_h, s, v) = rgb_to_hsv(255, 255, 255);
571 assert!((s - 0.0).abs() < 0.01);
572 assert!((v - 1.0).abs() < 0.01);
573 }
574}