1use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, LinearGradient, Point, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::overlay::panel;
14use crate::{Button, ButtonSize, ButtonStyle, ColorSwatch, IconName, TextField, View, util};
15
16const PANEL_WIDTH: f32 = 248.0;
17const FIELD_MIN_WIDTH: f32 = 140.0;
19const SHADE_HEIGHT: f32 = 148.0;
20const BAR_HEIGHT: f32 = 14.0;
21const THUMB: f32 = 12.0;
22const HUES: [Color; 7] = [
24 Color::rgb(1.0, 0.0, 0.0),
25 Color::rgb(1.0, 1.0, 0.0),
26 Color::rgb(0.0, 1.0, 0.0),
27 Color::rgb(0.0, 1.0, 1.0),
28 Color::rgb(0.0, 0.0, 1.0),
29 Color::rgb(1.0, 0.0, 1.0),
30 Color::rgb(1.0, 0.0, 0.0),
31];
32
33#[derive(Clone, Copy, Debug, PartialEq)]
36pub(crate) struct Hsv {
37 pub(crate) hue: f32,
39 pub(crate) saturation: f32,
41 pub(crate) value: f32,
43 pub(crate) alpha: f32,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl Hsv {
49 fn of(color: Color) -> Self {
50 let (r, g, b) = (color.r, color.g, color.b);
51 let top = r.max(g).max(b);
52 let span = top - r.min(g).min(b);
53 let hue = if span <= f32::EPSILON {
54 0.0
55 } else if top == r {
56 60.0 * ((g - b) / span).rem_euclid(6.0)
57 } else if top == g {
58 60.0 * ((b - r) / span + 2.0)
59 } else {
60 60.0 * ((r - g) / span + 4.0)
61 };
62 let saturation = if top <= f32::EPSILON { 0.0 } else { span / top };
63 Self { hue, saturation, value: top, alpha: color.a }
64 }
65
66 pub(crate) fn color(self) -> Color {
67 let chroma = self.value * self.saturation;
68 let part = self.hue.rem_euclid(360.0) / 60.0;
69 let second = chroma * (1.0 - (part.rem_euclid(2.0) - 1.0).abs());
70 let (r, g, b) = match part as u32 {
71 0 => (chroma, second, 0.0),
72 1 => (second, chroma, 0.0),
73 2 => (0.0, chroma, second),
74 3 => (0.0, second, chroma),
75 4 => (second, 0.0, chroma),
76 _ => (chroma, 0.0, second),
77 };
78 let lift = self.value - chroma;
79 Color::rgba(r + lift, g + lift, b + lift, self.alpha)
80 }
81}
82
83pub(crate) fn to_hex(color: Color) -> String {
85 let byte = |channel: f32| (channel.clamp(0.0, 1.0) * 255.0).round() as u8;
86 let rgb = format!("#{:02X}{:02X}{:02X}", byte(color.r), byte(color.g), byte(color.b));
87 if byte(color.a) == 255 { rgb } else { format!("{rgb}{:02X}", byte(color.a)) }
88}
89
90fn parse_hex(text: &str) -> Option<Color> {
92 let digits = text.trim().trim_start_matches('#');
93 if !digits.chars().all(|c| c.is_ascii_hexdigit()) {
94 return None;
95 }
96 let number = u32::from_str_radix(digits, 16).ok();
97 match digits.len() {
98 3 => number.map(|n| {
99 let spread = |d: u32| d * 0x11;
100 Color::hex(spread(n >> 8) << 16 | spread((n >> 4) & 0xF) << 8 | spread(n & 0xF))
101 }),
102 6 => number.map(Color::hex),
103 8 => number.map(Color::hex_alpha),
104 _ => None,
105 }
106}
107
108#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
122pub struct ColorPicker {
123 value: Binding<Option<Color>>,
124 palette: Vec<(Color, String)>,
125 alpha: bool,
126 width: Option<f32>,
127 label: Option<String>,
128 state: util::EditorState,
129}
130
131impl fmt::Debug for ColorPicker {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.debug_struct("ColorPicker").field("alpha", &self.alpha).finish_non_exhaustive()
134 }
135}
136
137#[cfg_attr(feature = "live", ravenrs_live::expose)]
138impl ColorPicker {
139 pub fn new(value: Binding<Option<Color>>) -> Self {
141 Self {
142 value,
143 palette: Vec::new(),
144 alpha: false,
145 width: None,
146 label: None,
147 state: util::EditorState::default(),
148 }
149 }
150
151 pub fn palette(mut self, colors: impl IntoIterator<Item = (Color, impl Into<String>)>) -> Self {
153 self.palette = colors.into_iter().map(|(color, name)| (color, name.into())).collect();
154 self
155 }
156
157 pub fn alpha(mut self, alpha: bool) -> Self {
159 self.alpha = alpha;
160 self
161 }
162
163 pub fn width(mut self, width: f32) -> Self {
165 self.width = Some(width);
166 self
167 }
168
169 pub fn label(mut self, label: impl Into<String>) -> Self {
171 self.label = Some(label.into());
172 self
173 }
174
175 pub fn disabled(mut self, disabled: bool) -> Self {
177 self.state.disabled = disabled;
178 self
179 }
180
181 pub fn read_only(mut self, read_only: bool) -> Self {
183 self.state.read_only = read_only;
184 self
185 }
186}
187
188#[derive(Clone)]
190struct Draft {
191 hsv: State<Hsv>,
192 value: Binding<Option<Color>>,
193}
194
195#[cfg_attr(feature = "live", ravenrs_live::expose)]
196impl Draft {
197 fn change(&self, f: impl FnOnce(&mut Hsv)) {
198 let mut hsv = *self.hsv.peek();
199 f(&mut hsv);
200 self.hsv.set(hsv);
201 self.value.set(Some(hsv.color()));
202 }
203}
204
205fn thumb(scene: &mut ravenrs_core::Scene, center: Point, fill: Color, ring: Color) {
206 let half = THUMB / 2.0;
207 let frame = Rect::new(center.x - half, center.y - half, THUMB, THUMB);
208 scene.push_quad(Quad::new(frame, fill).radius(half).border(2.0, ring));
209}
210
211struct ShadeSquare(Draft);
213
214impl View for ShadeSquare {
215 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
216 shade_square(cx, &self.0)
217 }
218}
219
220fn shade_square(cx: &mut BuildCx<'_>, draft: &Draft) -> NodeId {
221 let theme = *cx.theme();
222 let hsv = draft.hsv.get();
223 let ring = Color::WHITE;
224 let node = cx.container(
225 LayoutStyle { height: Some(SHADE_HEIGHT), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
226 &[],
227 Paint::Custom(Rc::new(move |f: Rect, scene| {
228 let radius = theme.radii.sm;
229 let pure = Hsv { saturation: 1.0, value: 1.0, alpha: 1.0, ..hsv }.color();
230 let across = LinearGradient {
231 start: Point::new(0.0, 0.0),
232 end: Point::new(1.0, 0.0),
233 from: Color::WHITE,
234 to: pure,
235 };
236 scene.push_quad(Quad::new(f, across).radius(radius));
237 let down = LinearGradient::vertical(Color::BLACK.with_alpha(0.0), Color::BLACK);
238 scene.push_quad(Quad::new(f, down).radius(radius));
239 let at = Point::new(f.x() + hsv.saturation * f.width(), f.y() + (1.0 - hsv.value) * f.height());
240 thumb(scene, at, Hsv { alpha: 1.0, ..hsv }.color(), ring);
241 })),
242 );
243 let name = cx.labels().color_shade.clone();
244 cx.semantics(
245 node,
246 Semantics::new(Role::Slider).name(name).value(format!(
247 "{}%, {}%",
248 (hsv.saturation * 100.0).round(),
249 (hsv.value * 100.0).round()
250 )),
251 );
252 cx.focusable(node, theme.radii.sm);
253 cx.cursor(node, CursorIcon::Pointer);
254 let dragged = draft.clone();
255 cx.on_drag(
256 node,
257 Rc::new(move |d| {
258 if d.phase == DragPhase::End {
259 return;
260 }
261 let x = ((d.position.x - d.frame.x()) / d.frame.width().max(1.0)).clamp(0.0, 1.0);
262 let y = ((d.position.y - d.frame.y()) / d.frame.height().max(1.0)).clamp(0.0, 1.0);
263 dragged.change(|hsv| {
264 hsv.saturation = x;
265 hsv.value = 1.0 - y;
266 });
267 }),
268 );
269 let keyed = draft.clone();
270 cx.on_key(
271 node,
272 Rc::new(move |key, _| {
273 let (x, y) = match key {
274 Key::ArrowLeft => (-0.02, 0.0),
275 Key::ArrowRight => (0.02, 0.0),
276 Key::ArrowUp => (0.0, 0.02),
277 Key::ArrowDown => (0.0, -0.02),
278 _ => return false,
279 };
280 keyed.change(|hsv| {
281 hsv.saturation = (hsv.saturation + x).clamp(0.0, 1.0);
282 hsv.value = (hsv.value + y).clamp(0.0, 1.0);
283 });
284 true
285 }),
286 );
287 node
288}
289
290type BarPaint = Rc<dyn Fn(Rect, &mut ravenrs_core::Scene)>;
291
292struct ColorBar {
294 name: String,
295 value: f32,
296 max: f32,
297 step: f32,
298 paint: BarPaint,
299 set: Rc<dyn Fn(f32)>,
300}
301
302impl View for ColorBar {
303 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
304 let theme = *cx.theme();
305 let (value, max, step) = (self.value, self.max, self.step);
306 let paint = self.paint.clone();
307 let node = cx.container(
308 LayoutStyle { height: Some(BAR_HEIGHT), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
309 &[],
310 Paint::Custom(Rc::new(move |f: Rect, scene| {
311 paint(f, scene);
312 let at = Point::new(f.x() + value / max * f.width(), f.center().y);
313 thumb(scene, at, Color::TRANSPARENT, Color::WHITE);
314 })),
315 );
316 cx.semantics(
317 node,
318 Semantics::new(Role::Slider).name(self.name.clone()).numeric(
319 f64::from(value),
320 0.0,
321 f64::from(max),
322 Some(f64::from(step)),
323 ),
324 );
325 cx.focusable(node, theme.radii.full);
326 cx.cursor(node, CursorIcon::Pointer);
327 let dragged = self.set.clone();
328 cx.on_drag(
329 node,
330 Rc::new(move |d| {
331 if d.phase != DragPhase::End {
332 dragged(d.fraction_x() * max);
333 }
334 }),
335 );
336 let set = self.set.clone();
337 cx.on_key(
338 node,
339 Rc::new(move |key, _| {
340 match key {
341 Key::ArrowLeft | Key::ArrowDown => set((value - step).max(0.0)),
342 Key::ArrowRight | Key::ArrowUp => set((value + step).min(max)),
343 _ => return false,
344 }
345 true
346 }),
347 );
348 node
349 }
350}
351
352struct ColorPanel<'a> {
353 picker: &'a ColorPicker,
354 draft: Draft,
355 open: State<bool>,
356}
357
358impl View for ColorPanel<'_> {
359 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
360 let theme = *cx.theme();
361 let labels = cx.labels();
362 let draft = &self.draft;
363 let hsv = draft.hsv.get();
364 let mut rows = Vec::new();
365 if !self.picker.palette.is_empty() {
366 let current = self.picker.value.get();
367 let mut swatches = Vec::with_capacity(self.picker.palette.len());
368 for (i, (color, name)) in self.picker.palette.iter().enumerate() {
369 let (d, color) = (draft.clone(), *color);
370 let on = current.is_some_and(|c| to_hex(c) == to_hex(color));
371 swatches.push(cx.keyed_child(
372 i as u64,
373 &ColorSwatch::new(color, name.clone()).selected(on).on_click(move || {
374 d.hsv.set(Hsv::of(color));
375 d.value.set(Some(color));
376 }),
377 ));
378 }
379 rows.push(cx.container(
380 LayoutStyle { wrap: true, ..LayoutStyle::stack(Axis::Horizontal) },
381 &swatches,
382 Paint::None,
383 ));
384 }
385 rows.push(cx.child(&ShadeSquare(draft.clone())));
386 let radius = theme.radii.full;
387 let d = draft.clone();
388 rows.push(cx.child(&ColorBar {
389 name: labels.color_hue.clone(),
390 value: hsv.hue,
391 max: 360.0,
392 step: 3.0,
393 paint: Rc::new(move |f, scene| {
394 let part = f.width() / (HUES.len() - 1) as f32;
395 for (i, pair) in HUES.windows(2).enumerate() {
396 let piece = Rect::new(f.x() + i as f32 * part, f.y(), part + 0.5, f.height());
397 let gradient = LinearGradient {
398 start: Point::new(0.0, 0.0),
399 end: Point::new(1.0, 0.0),
400 from: pair[0],
401 to: pair[1],
402 };
403 scene.push_quad(Quad::new(piece, gradient));
404 }
405 }),
406 set: Rc::new(move |value| d.change(|hsv| hsv.hue = value.min(359.9))),
407 }));
408 if self.picker.alpha {
409 let d = draft.clone();
410 let solid = Hsv { alpha: 1.0, ..hsv }.color();
411 rows.push(cx.child(&ColorBar {
412 name: labels.color_alpha.clone(),
413 value: hsv.alpha,
414 max: 1.0,
415 step: 0.02,
416 paint: Rc::new(move |f, scene| {
417 let gradient = LinearGradient {
418 start: Point::new(0.0, 0.0),
419 end: Point::new(1.0, 0.0),
420 from: solid.with_alpha(0.0),
421 to: solid,
422 };
423 scene.push_quad(Quad::new(f, gradient).radius(radius));
424 }),
425 set: Rc::new(move |value| d.change(|hsv| hsv.alpha = value)),
426 }));
427 }
428 let typing: State<Option<String>> = cx.use_state(|| None);
430 let (shown, kept, d, opaque) = (typing.clone(), typing.clone(), draft.clone(), !self.picker.alpha);
431 let current = to_hex(hsv.color());
432 let hex = Binding::new(
433 move || shown.get().unwrap_or_else(|| current.clone()),
434 move |text: String| {
435 if let Some(color) = parse_hex(&text) {
436 let color = if opaque { color.with_alpha(1.0) } else { color };
437 d.hsv.set(Hsv::of(color));
438 d.value.set(Some(color));
439 }
440 kept.set(Some(text));
441 },
442 );
443 let field =
444 cx.child(&TextField::new(hex).placeholder(labels.color_hex.clone()).width(116.0).compact());
445 let value = self.picker.value.clone();
446 let close = self.open.clone();
447 let none = cx.child(
448 &Button::new(labels.no_color.clone()).style(ButtonStyle::Ghost).size(ButtonSize::Small).on_click(
449 move || {
450 value.set(None);
451 close.set(false);
452 },
453 ),
454 );
455 rows.push(cx.container(
456 LayoutStyle { spacing: 8.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
457 &[field, none],
458 Paint::None,
459 ));
460 let inner = cx.container(
461 LayoutStyle { spacing: 10.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
462 &rows,
463 Paint::None,
464 );
465 let node = panel(cx, &[inner], 12.0, PANEL_WIDTH);
466 cx.semantics(node, Semantics::new(Role::Dialog).name(labels.pick_color.clone()));
467 let closing = self.open.clone();
468 cx.on_key(
469 node,
470 Rc::new(move |key, _| {
471 let done = *key == Key::Escape;
472 if done {
473 closing.set(false);
474 }
475 done
476 }),
477 );
478 node
479 }
480}
481
482impl View for ColorPicker {
483 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
484 let theme = *cx.theme();
485 let open: State<bool> = cx.use_state(|| false);
486 let state = self.state;
487 let is_open = open.get() && state.accepts_input();
488 let hovered = cx.is_hovered() && state.accepts_input();
489 let current = self.value.get();
490 let start = current.unwrap_or(Color::hex(0x3B82F6));
491 let hsv: State<Hsv> = cx.use_state(move || Hsv::of(start));
492 if let Some(color) = current
494 && to_hex(hsv.peek().color()) != to_hex(color)
495 {
496 hsv.set_if_changed(Hsv::of(color));
497 }
498 let (text, color) = match current {
499 Some(color) => (to_hex(color), theme.color(Semantic::Ink)),
500 None => (cx.labels().no_color.clone(), theme.color(Semantic::InkMuted)),
501 };
502 let edge = theme.color(Semantic::Border);
503 let sample = cx.container(
504 LayoutStyle { width: Some(20.0), height: Some(20.0), ..LayoutStyle::leaf() },
505 &[],
506 Paint::Quad(QuadPaint {
507 corner_radius: theme.radii.sm,
508 border: Some(Border { width: 1.0, color: edge }),
509 ..QuadPaint::new(state.ink(current.unwrap_or(Color::TRANSPARENT)))
510 }),
511 );
512 let label = util::label(cx, &text, TextStyle::Body, None, state.ink(color));
513 cx.semantics(label, Semantics::hidden());
514 let chevron = util::icon(cx, IconName::ChevronDown, 16.0, state.ink(theme.color(Semantic::InkMuted)));
515 let filler = cx.container(LayoutStyle { grow: 1.0, ..LayoutStyle::leaf() }, &[], Paint::None);
516 let border = if is_open {
517 Border { width: 1.5, color: theme.color(Semantic::Accent) }
518 } else {
519 Border {
520 width: 1.0,
521 color: state.ink(if hovered { edge.lerp(theme.color(Semantic::Ink), 0.25) } else { edge }),
522 }
523 };
524 let node = cx.container(
525 LayoutStyle {
526 width: self.width,
527 min_width: Some(FIELD_MIN_WIDTH),
528 height: Some(40.0),
529 padding: Insets::symmetric(0.0, 12.0),
530 spacing: 8.0,
531 align: Align::Center,
532 align_self: Some(if self.width.is_some() { Align::Fit } else { Align::Stretch }),
533 ..LayoutStyle::stack(Axis::Horizontal)
534 },
535 &[sample, label, filler, chevron],
536 Paint::Quad(QuadPaint {
537 corner_radius: theme.radii.md,
538 border: Some(border),
539 ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
540 }),
541 );
542 let mut semantics = Semantics::new(Role::ComboBox).value(text).expanded(is_open);
543 if let Some(label) = &self.label {
544 semantics = semantics.name(label.clone());
545 }
546 cx.semantics(node, state.semantics(semantics));
547 if state.disabled {
548 return node;
549 }
550 cx.focusable(node, theme.radii.md);
551 if !state.accepts_input() {
552 return node;
553 }
554 let toggle = open.clone();
555 cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
556 let opener = open.clone();
557 cx.on_key(
558 node,
559 Rc::new(move |key, _| {
560 let opens = matches!(key, Key::ArrowDown | Key::ArrowUp);
561 if opens {
562 opener.set(true);
563 }
564 opens
565 }),
566 );
567 cx.cursor(node, CursorIcon::Pointer);
568 let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
569 if is_open {
570 was_open.set(true);
571 let close = open.clone();
572 let body = ColorPanel {
573 picker: self,
574 draft: Draft { hsv, value: self.value.clone() },
575 open: open.clone(),
576 };
577 cx.overlay(node, Placement::Below, &body, Some(Rc::new(move || close.set(false))));
578 } else if was_open.replace(false) {
579 cx.request_focus();
580 }
581 node
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn colors_go_to_hsv_and_back() {
591 for hex in [0xFF0000, 0x00FF00, 0x0000FF, 0x2563EB, 0xF59E0B, 0x808080, 0x000000, 0xFFFFFF] {
592 let color = Color::hex(hex);
593 assert_eq!(to_hex(Hsv::of(color).color()), to_hex(color));
594 }
595 }
596
597 #[test]
598 fn known_hues() {
599 assert_eq!(Hsv::of(Color::hex(0xFF0000)).hue, 0.0);
600 assert_eq!(Hsv::of(Color::hex(0x00FF00)).hue, 120.0);
601 assert_eq!(Hsv::of(Color::hex(0x0000FF)).hue, 240.0);
602 assert_eq!(Hsv::of(Color::hex(0x808080)).saturation, 0.0);
603 }
604
605 #[test]
606 fn hexadecimal_is_read_in_every_length() {
607 assert_eq!(parse_hex("#2563EB").map(to_hex).as_deref(), Some("#2563EB"));
608 assert_eq!(parse_hex("2563eb").map(to_hex).as_deref(), Some("#2563EB"));
609 assert_eq!(parse_hex("#F0A").map(to_hex).as_deref(), Some("#FF00AA"));
610 assert_eq!(parse_hex("#2563EB80").map(to_hex).as_deref(), Some("#2563EB80"));
611 for wrong in ["", "#12", "#12345", "#GGGGGG", "+12345", "#1234567"] {
612 assert_eq!(parse_hex(wrong), None, "{wrong}");
613 }
614 }
615}