ccf_gpui_widgets/widgets/color_swatch.rs
1//! Color swatch widget
2//!
3//! A color preview with hex input and color picker. Displays a colored square alongside
4//! a text input for the hex color value. Clicking the swatch opens a full color picker
5//! with RGB/HSL sliders and a 2D saturation/lightness selector.
6//!
7//! # Features
8//!
9//! - Hex color input (#RGB, #RRGGBB, #RRGGBBAA)
10//! - CSS named color support (140 colors: "red", "coral", "darkblue", etc.)
11//! - Color picker popup with RGB and HSL modes
12//! - 2D saturation/lightness selector canvas
13//! - Hue rainbow slider
14//! - Optional alpha channel support
15//! - Old/new color preview
16//!
17//! # Example
18//!
19//! ```ignore
20//! use ccf_gpui_widgets::widgets::ColorSwatch;
21//!
22//! let swatch = cx.new(|cx| {
23//! ColorSwatch::new(cx)
24//! .value("#3b82f6")
25//! .with_alpha(true)
26//! });
27//!
28//! // Subscribe to changes
29//! cx.subscribe(&swatch, |this, _swatch, event: &ColorSwatchEvent, cx| {
30//! if let ColorSwatchEvent::Change(hex) = event {
31//! println!("Color: {}", hex);
32//! }
33//! }).detach();
34//! ```
35
36use std::cell::Cell;
37use std::rc::Rc;
38
39use gpui::prelude::*;
40use gpui::*;
41
42use crate::theme::{get_theme_or, Theme};
43use crate::utils::color::{Rgb, Hsl, Hsv, parse_color, parse_color_alpha};
44use super::text_input::{TextInput, TextInputEvent};
45use super::focus_navigation::{FocusNext, FocusPrev};
46use super::button::{primary_button, secondary_button};
47
48// Actions for keyboard navigation
49actions!(ccf_color_swatch, [ClosePicker, ApplyPicker]);
50
51/// Register key bindings for color swatch components
52///
53/// Call this once at application startup:
54/// ```ignore
55/// ccf_gpui_widgets::widgets::color_swatch::register_keybindings(cx);
56/// ```
57pub fn register_keybindings(cx: &mut App) {
58 cx.bind_keys([
59 KeyBinding::new("escape", ClosePicker, Some("CcfColorPicker")),
60 KeyBinding::new("enter", ApplyPicker, Some("CcfColorPicker")),
61 ]);
62}
63
64/// Drag state for saturation/lightness canvas
65#[doc(hidden)]
66#[derive(Clone)]
67struct SlDrag {
68 canvas_origin: Rc<Cell<Point<Pixels>>>,
69 canvas_width: f32,
70 canvas_height: f32,
71}
72
73/// Drag state for hue slider
74#[doc(hidden)]
75#[derive(Clone)]
76struct HueDrag {
77 origin: Rc<Cell<f32>>,
78 width: Rc<Cell<f32>>,
79}
80
81/// Drag state for alpha slider
82#[doc(hidden)]
83#[derive(Clone)]
84struct AlphaDrag {
85 origin: Rc<Cell<f32>>,
86 width: Rc<Cell<f32>>,
87}
88
89/// Drag state for component sliders (R, G, B, S, L)
90#[doc(hidden)]
91#[derive(Clone)]
92struct ComponentDrag {
93 origin: Rc<Cell<f32>>,
94 width: Rc<Cell<f32>>,
95 handle_visual_width: f32,
96 max_value: f32,
97 update_fn: fn(&mut ColorSwatch, f32, &mut Context<ColorSwatch>),
98}
99
100/// Empty view for drag visualization (we don't need a visual)
101#[doc(hidden)]
102struct EmptyDragView;
103
104impl Render for EmptyDragView {
105 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
106 div().size_0()
107 }
108}
109
110/// Events emitted by ColorSwatch
111#[derive(Clone, Debug)]
112pub enum ColorSwatchEvent {
113 /// Color value changed
114 Change(String),
115}
116
117/// Color picker mode (RGB or HSL sliders)
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum PickerMode {
120 Rgb,
121 Hsl,
122}
123
124/// Color swatch widget with hex input and color picker
125pub struct ColorSwatch {
126 /// Current color value as hex (#RRGGBB or #RRGGBBAA)
127 value: String,
128 /// Placeholder text
129 placeholder: String,
130 /// Whether alpha channel is enabled
131 with_alpha: bool,
132 /// Whether the widget is enabled
133 enabled: bool,
134 /// Custom theme
135 custom_theme: Option<Theme>,
136 /// Focus handle (for focus navigation, not key capture)
137 focus_handle: FocusHandle,
138 /// Focus handle for the picker popup (for ESC key handling)
139 picker_focus_handle: FocusHandle,
140 /// Text input for hex editing
141 hex_input: Entity<TextInput>,
142 /// Whether picker popup is open
143 is_picker_open: bool,
144 /// Current RGB values
145 current_rgb: Rgb,
146 /// Current HSL values (used for H slider and conversion)
147 current_hsl: Hsl,
148 /// Current HSV values (used for the S/V canvas)
149 current_hsv: Hsv,
150 /// Current alpha value (0-255)
151 current_alpha: u8,
152 /// Original color when picker opened (for comparison)
153 original_value: String,
154 /// Whether the text input needs to be synced with value
155 needs_input_sync: bool,
156 /// Whether the current input is valid
157 input_is_valid: bool,
158 /// Measured hue slider width (persists between frames)
159 hue_slider_width: Rc<Cell<f32>>,
160 /// Measured hue slider origin (persists between frames)
161 hue_slider_origin: Rc<Cell<f32>>,
162 /// Measured alpha slider width (persists between frames)
163 alpha_slider_width: Rc<Cell<f32>>,
164 /// Measured alpha slider origin (persists between frames)
165 alpha_slider_origin: Rc<Cell<f32>>,
166}
167
168impl EventEmitter<ColorSwatchEvent> for ColorSwatch {}
169
170impl Focusable for ColorSwatch {
171 fn focus_handle(&self, _cx: &App) -> FocusHandle {
172 self.focus_handle.clone()
173 }
174}
175
176impl ColorSwatch {
177 /// Create a new color swatch
178 pub fn new(cx: &mut Context<Self>) -> Self {
179 let hex_input = cx.new(|cx| {
180 TextInput::new(cx)
181 .placeholder("#000000")
182 .with_value("#000000")
183 });
184
185 // Subscribe to text input events
186 cx.subscribe(&hex_input, |this, input, event: &TextInputEvent, cx| {
187 match event {
188 TextInputEvent::Change => {
189 // Get the current input value and update preview
190 let value = input.read(cx).content().to_string();
191 this.handle_input_change(&value, cx);
192 }
193 TextInputEvent::Enter | TextInputEvent::Blur => {
194 // Try to parse as named color or hex on Enter/Blur
195 let value = input.read(cx).content().to_string();
196 this.handle_input_commit(&value, cx);
197 }
198 _ => {}
199 }
200 }).detach();
201
202 Self {
203 value: "#000000".to_string(),
204 placeholder: "#000000".to_string(),
205 with_alpha: false,
206 enabled: true,
207 custom_theme: None,
208 focus_handle: cx.focus_handle(),
209 picker_focus_handle: cx.focus_handle(),
210 hex_input,
211 is_picker_open: false,
212 current_rgb: Rgb::new(0, 0, 0),
213 current_hsl: Hsl::new(0.0, 0.0, 0.0),
214 current_hsv: Hsv::new(0.0, 0.0, 0.0),
215 current_alpha: 255,
216 original_value: "#000000".to_string(),
217 needs_input_sync: false,
218 input_is_valid: true,
219 // Initial estimates, will be updated by prepaint
220 hue_slider_width: Rc::new(Cell::new(200.0)),
221 hue_slider_origin: Rc::new(Cell::new(0.0)),
222 alpha_slider_width: Rc::new(Cell::new(200.0)),
223 alpha_slider_origin: Rc::new(Cell::new(0.0)),
224 }
225 }
226
227 /// Set initial value (builder pattern)
228 /// Accepts hex colors (#RGB, #RRGGBB, #RRGGBBAA) or CSS named colors
229 #[must_use]
230 pub fn with_value(mut self, color: impl Into<String>) -> Self {
231 let color_str = color.into();
232 // Try to parse as hex or named color
233 if let Some(rgba) = parse_color_alpha(&color_str) {
234 self.current_rgb = Rgb::new(rgba.r, rgba.g, rgba.b);
235 self.current_hsl = self.current_rgb.to_hsl();
236 self.current_hsv = self.current_rgb.to_hsv();
237 self.current_alpha = rgba.a;
238 self.value = if self.with_alpha && rgba.a != 255 {
239 format!("#{:02X}{:02X}{:02X}{:02X}", rgba.r, rgba.g, rgba.b, rgba.a)
240 } else {
241 format!("#{:02X}{:02X}{:02X}", rgba.r, rgba.g, rgba.b)
242 };
243 // Flag that text input needs to be synced on first render
244 self.needs_input_sync = true;
245 }
246 self
247 }
248
249 /// Set placeholder text (builder pattern)
250 #[must_use]
251 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
252 self.placeholder = text.into();
253 self
254 }
255
256 /// Enable or disable alpha channel support (builder pattern)
257 #[must_use]
258 pub fn with_alpha(mut self, enabled: bool) -> Self {
259 self.with_alpha = enabled;
260 self
261 }
262
263 /// Set enabled state (builder pattern)
264 #[must_use]
265 pub fn with_enabled(mut self, enabled: bool) -> Self {
266 self.enabled = enabled;
267 self
268 }
269
270 /// Set custom theme (builder pattern)
271 #[must_use]
272 pub fn theme(mut self, theme: Theme) -> Self {
273 self.custom_theme = Some(theme);
274 self
275 }
276
277 /// Get the current hex value
278 pub fn value(&self) -> &str {
279 &self.value
280 }
281
282 /// Get current RGB value
283 pub fn rgb(&self) -> Rgb {
284 self.current_rgb
285 }
286
287 /// Get current HSL value
288 pub fn hsl(&self) -> Hsl {
289 self.current_hsl
290 }
291
292 /// Get current alpha value (0-255)
293 pub fn alpha(&self) -> u8 {
294 self.current_alpha
295 }
296
297 /// Check if the widget is enabled
298 pub fn is_enabled(&self) -> bool {
299 self.enabled
300 }
301
302 /// Set enabled state programmatically
303 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
304 if self.enabled != enabled {
305 self.enabled = enabled;
306 // Sync enabled state to the hex input
307 self.hex_input.update(cx, |input, cx| {
308 input.set_enabled(enabled, cx);
309 });
310 cx.notify();
311 }
312 }
313
314 /// Set value programmatically
315 pub fn set_value(&mut self, color: &str, cx: &mut Context<Self>) {
316 self.set_value_internal(color, cx);
317 cx.emit(ColorSwatchEvent::Change(self.value.clone()));
318 }
319
320 /// Get the focus handle
321 pub fn focus_handle(&self) -> &FocusHandle {
322 &self.focus_handle
323 }
324
325 /// Internal set value without emitting event
326 fn set_value_internal(&mut self, color: &str, cx: &mut Context<Self>) {
327 // Try to parse as hex or named color
328 if let Some(rgba) = parse_color_alpha(color) {
329 self.current_rgb = Rgb::new(rgba.r, rgba.g, rgba.b);
330 self.current_hsl = self.current_rgb.to_hsl();
331 self.current_hsv = self.current_rgb.to_hsv();
332 self.current_alpha = rgba.a;
333 self.value = if self.with_alpha && rgba.a != 255 {
334 format!("#{:02X}{:02X}{:02X}{:02X}", rgba.r, rgba.g, rgba.b, rgba.a)
335 } else {
336 format!("#{:02X}{:02X}{:02X}", rgba.r, rgba.g, rgba.b)
337 };
338 // Update text input
339 self.hex_input.update(cx, |input, cx| {
340 input.set_value(&self.value, cx);
341 });
342 }
343 cx.notify();
344 }
345
346 /// Handle input text change (live preview without committing)
347 fn handle_input_change(&mut self, value: &str, cx: &mut Context<Self>) {
348 // Try to parse and update preview
349 if let Some(rgb) = parse_color(value) {
350 self.current_rgb = rgb;
351 self.current_hsl = rgb.to_hsl();
352 self.current_hsv = rgb.to_hsv();
353 // Update the value but don't update the text input (user is typing)
354 self.value = format!("#{:02X}{:02X}{:02X}", rgb.r, rgb.g, rgb.b);
355 self.input_is_valid = true;
356 cx.emit(ColorSwatchEvent::Change(self.value.clone()));
357 } else {
358 self.input_is_valid = false;
359 }
360 cx.notify();
361 }
362
363 /// Handle input commit (Enter/Blur) - parse named colors
364 fn handle_input_commit(&mut self, value: &str, cx: &mut Context<Self>) {
365 if let Some(rgba) = parse_color_alpha(value) {
366 self.current_rgb = Rgb::new(rgba.r, rgba.g, rgba.b);
367 self.current_hsl = self.current_rgb.to_hsl();
368 self.current_hsv = self.current_rgb.to_hsv();
369 self.current_alpha = rgba.a;
370 self.value = if self.with_alpha && rgba.a != 255 {
371 format!("#{:02X}{:02X}{:02X}{:02X}", rgba.r, rgba.g, rgba.b, rgba.a)
372 } else {
373 format!("#{:02X}{:02X}{:02X}", rgba.r, rgba.g, rgba.b)
374 };
375 self.input_is_valid = true;
376 // Update text input to show hex value (convert named colors)
377 self.hex_input.update(cx, |input, cx| {
378 input.set_value(&self.value, cx);
379 });
380 cx.emit(ColorSwatchEvent::Change(self.value.clone()));
381 } else {
382 self.input_is_valid = false;
383 }
384 cx.notify();
385 }
386
387 /// Check if the current input is valid
388 pub fn is_input_valid(&self) -> bool {
389 self.input_is_valid
390 }
391
392 /// Update from RGB values
393 fn update_from_rgb(&mut self, r: u8, g: u8, b: u8, cx: &mut Context<Self>) {
394 self.current_rgb = Rgb::new(r, g, b);
395 self.current_hsl = self.current_rgb.to_hsl();
396 self.current_hsv = self.current_rgb.to_hsv();
397 self.sync_value(cx);
398 }
399
400 /// Update from HSV values (used by the S/V canvas)
401 ///
402 /// Updates all internal color representations (HSV, RGB, HSL) while preserving
403 /// the hue value in HSL to keep the hue slider consistent.
404 fn update_from_hsv(&mut self, h: f32, s: f32, v: f32, cx: &mut Context<Self>) {
405 self.current_hsv = Hsv::new(h, s, v);
406 self.current_rgb = self.current_hsv.to_rgb();
407 self.current_hsl = self.current_rgb.to_hsl();
408 // Preserve hue in HSL to match HSV hue
409 self.current_hsl = Hsl::new(h, self.current_hsl.s, self.current_hsl.l);
410 self.sync_value(cx);
411 }
412
413 /// Sync value string and text input from current RGB
414 fn sync_value(&mut self, cx: &mut Context<Self>) {
415 let rgb = self.current_rgb;
416 self.value = if self.with_alpha && self.current_alpha != 255 {
417 format!("#{:02X}{:02X}{:02X}{:02X}", rgb.r, rgb.g, rgb.b, self.current_alpha)
418 } else {
419 format!("#{:02X}{:02X}{:02X}", rgb.r, rgb.g, rgb.b)
420 };
421 self.hex_input.update(cx, |input, cx| {
422 input.set_value(&self.value, cx);
423 });
424 cx.emit(ColorSwatchEvent::Change(self.value.clone()));
425 cx.notify();
426 }
427
428 /// Open the color picker popup
429 fn open_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
430 self.original_value = self.value.clone();
431 self.is_picker_open = true;
432 self.picker_focus_handle.focus(window);
433 cx.notify();
434 }
435
436 /// Close the color picker popup
437 fn close_picker(&mut self, cx: &mut Context<Self>) {
438 self.is_picker_open = false;
439 cx.notify();
440 }
441
442 /// Cancel and revert to original color
443 fn cancel_picker(&mut self, cx: &mut Context<Self>) {
444 // Revert to original value
445 self.set_value_internal(&self.original_value.clone(), cx);
446 self.is_picker_open = false;
447 cx.notify();
448 }
449
450 /// Apply current color and close
451 fn apply_picker(&mut self, cx: &mut Context<Self>) {
452 // Value is already set, just close
453 self.is_picker_open = false;
454 cx.notify();
455 }
456
457 /// Parse the current value to get a GPUI Rgba for display
458 fn parse_display_color(&self) -> Rgba {
459 let rgb = self.current_rgb;
460 let a = self.current_alpha;
461 rgba(((rgb.r as u32) << 24) | ((rgb.g as u32) << 16) | ((rgb.b as u32) << 8) | (a as u32))
462 }
463
464 /// Parse original value for comparison display
465 fn parse_original_color(&self) -> Rgba {
466 if let Some(rgba_val) = parse_color_alpha(&self.original_value) {
467 rgba(((rgba_val.r as u32) << 24) | ((rgba_val.g as u32) << 16) | ((rgba_val.b as u32) << 8) | (rgba_val.a as u32))
468 } else {
469 rgba(0x000000FF)
470 }
471 }
472
473 /// Handle S/V canvas interaction at position (HSV model)
474 ///
475 /// Converts mouse position to saturation/value coordinates:
476 /// - X axis = Saturation (0% left to 100% right)
477 /// - Y axis = Value/Brightness (100% top to 0% bottom)
478 fn handle_sl_at_position(&mut self, x: f32, y: f32, origin: Point<Pixels>, canvas_width: f32, canvas_height: f32, cx: &mut Context<Self>) {
479 let origin_x: f32 = origin.x.into();
480 let origin_y: f32 = origin.y.into();
481 let rel_x = (x - origin_x).clamp(0.0, canvas_width);
482 let rel_y = (y - origin_y).clamp(0.0, canvas_height);
483
484 let s = (rel_x / canvas_width) * 100.0;
485 let v = (1.0 - rel_y / canvas_height) * 100.0;
486 self.update_from_hsv(self.current_hsv.h, s, v, cx);
487 }
488
489 /// Handle hue slider interaction at position
490 ///
491 /// Converts mouse position to hue value (0-359°). Hue is clamped to prevent
492 /// wrap-around (360° = 0° = red).
493 fn handle_hue_at_position(&mut self, x: f32, origin_x: f32, slider_width: f32, cx: &mut Context<Self>) {
494 // Must match the display calculation
495 // Note: border doesn't affect layout width in GPUI, only content width matters
496 let handle_width = 4.0f32;
497 let usable_width = slider_width - handle_width;
498 // Guard against division by zero (can happen if slider hasn't been measured yet)
499 if usable_width <= 0.0 {
500 return;
501 }
502 // Map click position to handle left edge position, then to value
503 // Clicking anywhere on the slider should work, with clamping at edges
504 let rel_x = (x - origin_x - handle_width / 2.0).clamp(0.0, usable_width);
505 // Cap at 359 to prevent wrap-around to pure red (360° = 0°)
506 let h = (rel_x / usable_width) * 359.0;
507 // Use HSV for hue changes to keep S/V canvas consistent
508 self.update_from_hsv(h, self.current_hsv.s, self.current_hsv.v, cx);
509 }
510
511 /// Handle alpha slider interaction at position
512 ///
513 /// Converts mouse position to alpha value (0-255).
514 fn handle_alpha_at_position(&mut self, x: f32, origin_x: f32, slider_width: f32, cx: &mut Context<Self>) {
515 // Must match the display calculation
516 // Note: border doesn't affect layout width in GPUI, only content width matters
517 let handle_width = 4.0f32;
518 let usable_width = slider_width - handle_width;
519 // Guard against division by zero (can happen if slider hasn't been measured yet)
520 if usable_width <= 0.0 {
521 return;
522 }
523 // Map click position to handle left edge position, then to value
524 let rel_x = (x - origin_x - handle_width / 2.0).clamp(0.0, usable_width);
525 self.current_alpha = ((rel_x / usable_width) * 255.0) as u8;
526 self.sync_value(cx);
527 }
528}
529
530impl Render for ColorSwatch {
531 fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
532 // Sync text input if needed (for builder pattern with_value)
533 if self.needs_input_sync {
534 self.needs_input_sync = false;
535 let value = self.value.clone();
536 let enabled = self.enabled;
537 self.hex_input.update(cx, |input, cx| {
538 input.set_value(&value, cx);
539 input.set_enabled(enabled, cx);
540 });
541 }
542
543 let theme = get_theme_or(cx, self.custom_theme.as_ref());
544 let color = self.parse_display_color();
545 let is_picker_open = self.is_picker_open;
546 let hex_input = self.hex_input.clone();
547 let enabled = self.enabled;
548
549 let bg_popup = theme.bg_secondary;
550 let border_checkbox = theme.border_checkbox;
551 let border_input = theme.border_input;
552 let text_color = theme.text_primary;
553 let picker_focus_handle = self.picker_focus_handle.clone();
554
555 div()
556 .id("ccf_color_swatch")
557 .relative()
558 // Focus navigation (Tab / Shift+Tab) - but don't track focus, let TextInput handle it
559 .on_action(cx.listener(|this, _: &FocusNext, window, _cx| {
560 if !this.enabled {
561 return;
562 }
563 window.focus_next();
564 }))
565 .on_action(cx.listener(|this, _: &FocusPrev, window, _cx| {
566 if !this.enabled {
567 return;
568 }
569 window.focus_prev();
570 }))
571 .child(
572 div()
573 .flex()
574 .flex_row()
575 .gap_2()
576 .items_center()
577 .child(
578 // Color preview box (clickable to open picker)
579 div()
580 .id("ccf_color_preview")
581 .relative()
582 .w(px(40.))
583 .h(px(32.))
584 .border_1()
585 .border_color(rgb(border_checkbox))
586 .rounded_md()
587 .overflow_hidden()
588 .when(enabled, |d| d.cursor_pointer())
589 .when(!enabled, |d| d.cursor_default().opacity(0.5))
590 // Checkerboard background for alpha visualization
591 .when(self.with_alpha, |d| d.child(Self::render_checkerboard()))
592 // Color overlay
593 .child(
594 div()
595 .size_full()
596 .absolute()
597 .bg(color)
598 )
599 .on_click(cx.listener(|this, _event, window, cx| {
600 if !this.enabled {
601 return;
602 }
603 if this.is_picker_open {
604 this.close_picker(cx);
605 } else {
606 this.open_picker(window, cx);
607 }
608 }))
609 )
610 .child(
611 // Hex color text input with error border
612 div()
613 .flex_1()
614 .border_2()
615 .rounded_md()
616 .border_color(if self.input_is_valid {
617 rgba(0x00000000)
618 } else {
619 rgb(theme.border_error)
620 })
621 .child(hex_input)
622 )
623 )
624 // Color picker popup
625 .when(is_picker_open, |parent| {
626 let current_rgb = self.current_rgb;
627 let current_hsv = self.current_hsv;
628 let current_alpha = self.current_alpha;
629 let with_alpha = self.with_alpha;
630 let original_color = self.parse_original_color();
631 let new_color = self.parse_display_color();
632 let original_hex = self.original_value.clone();
633 let new_hex = self.value.clone();
634
635 // 2D S/V canvas dimensions (HSV model)
636 let canvas_width = 200.0f32;
637 let canvas_height = 150.0f32;
638 let hue = current_hsv.h;
639
640 // Canvas origin for mouse handling (shared via Rc<Cell<>>)
641 let canvas_origin = Rc::new(Cell::new(Point::default()));
642 let canvas_origin_for_paint = canvas_origin.clone();
643 let canvas_origin_for_drag = canvas_origin.clone();
644
645 // Hue slider - use persistent fields from struct
646 let hue_origin = self.hue_slider_origin.clone();
647 let hue_origin_for_paint = hue_origin.clone();
648 let hue_origin_for_drag = hue_origin.clone();
649 let hue_width = self.hue_slider_width.clone();
650 let hue_width_for_paint = hue_width.clone();
651 let hue_width_for_drag = hue_width.clone();
652
653 // Alpha slider - use persistent fields from struct
654 let alpha_origin = self.alpha_slider_origin.clone();
655 let alpha_origin_for_paint = alpha_origin.clone();
656 let alpha_origin_for_drag = alpha_origin.clone();
657 let alpha_width = self.alpha_slider_width.clone();
658 let alpha_width_for_paint = alpha_width.clone();
659 let alpha_width_for_drag = alpha_width.clone();
660
661 parent.child(
662 deferred(
663 anchored()
664 .anchor(Corner::TopLeft)
665 .child(
666 div()
667 .id("ccf_color_picker")
668 .key_context("CcfColorPicker")
669 .track_focus(&picker_focus_handle)
670 .on_action(cx.listener(|this, _: &ClosePicker, _window, cx| {
671 this.cancel_picker(cx);
672 }))
673 .on_action(cx.listener(|this, _: &ApplyPicker, _window, cx| {
674 this.apply_picker(cx);
675 }))
676 .occlude()
677 .absolute()
678 .top(px(4.)) // Small gap below the main control
679 .left_0()
680 .w(px(280.))
681 .p_3()
682 .bg(rgb(bg_popup))
683 .border_1()
684 .border_color(rgb(border_input))
685 .rounded_lg()
686 .shadow_lg()
687 .flex()
688 .flex_col()
689 .gap_3()
690 // 2D Saturation/Lightness canvas
691 .child(
692 div()
693 .id("sl_canvas")
694 .relative()
695 .w(px(canvas_width))
696 .h(px(canvas_height))
697 .rounded_md()
698 .border_1()
699 .border_color(rgb(border_input))
700 .overflow_hidden()
701 .cursor_crosshair()
702 // Background is the hue at full saturation (HSV: S=100%, V=100%)
703 .bg(rgb(Hsv::new(hue, 100.0, 100.0).to_rgb().to_u32()))
704 .child(
705 // Canvas for gradients
706 canvas(
707 move |bounds, _window, _cx| {
708 canvas_origin_for_paint.set(bounds.origin);
709 bounds
710 },
711 move |bounds, _prepaint_result, window, _cx| {
712 // Paint white gradient (left to right): 90 degrees
713 let white_start = linear_color_stop(white(), 0.0);
714 let white_end = linear_color_stop(transparent_white(), 1.0);
715 let white_gradient = linear_gradient(90.0, white_start, white_end);
716 window.paint_quad(fill(bounds, white_gradient));
717
718 // Paint black gradient (top to bottom): 180 degrees
719 let black_start = linear_color_stop(transparent_black(), 0.0);
720 let black_end = linear_color_stop(black(), 1.0);
721 let black_gradient = linear_gradient(180.0, black_start, black_end);
722 window.paint_quad(fill(bounds, black_gradient));
723 },
724 )
725 .size_full()
726 .absolute()
727 )
728 // Crosshair indicator
729 .child({
730 // Position based on current S/V (HSV model)
731 let s = current_hsv.s / 100.0;
732 let v = current_hsv.v / 100.0;
733 let x = s * canvas_width;
734 let y = (1.0 - v) * canvas_height;
735
736 div()
737 .absolute()
738 .left(px(x - 6.0))
739 .top(px(y - 6.0))
740 .w(px(12.))
741 .h(px(12.))
742 .rounded_full()
743 .border_2()
744 .border_color(rgb(theme.bg_white))
745 .shadow_sm()
746 })
747 .on_mouse_down(MouseButton::Left, {
748 let canvas_origin = canvas_origin_for_drag.clone();
749 cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
750 let x: f32 = event.position.x.into();
751 let y: f32 = event.position.y.into();
752 this.handle_sl_at_position(x, y, canvas_origin.get(), canvas_width, canvas_height, cx);
753 })
754 })
755 .on_drag(
756 SlDrag {
757 canvas_origin: canvas_origin_for_drag.clone(),
758 canvas_width,
759 canvas_height,
760 },
761 |_drag, _offset, _window, cx| cx.new(|_| EmptyDragView),
762 )
763 .on_drag_move(cx.listener(move |this, event: &DragMoveEvent<SlDrag>, _window, cx| {
764 let x: f32 = event.event.position.x.into();
765 let y: f32 = event.event.position.y.into();
766 let drag = event.drag(cx);
767 this.handle_sl_at_position(x, y, drag.canvas_origin.get(), drag.canvas_width, drag.canvas_height, cx);
768 }))
769 )
770 // Hue slider
771 .child(
772 div()
773 .flex()
774 .flex_row()
775 .items_center()
776 .gap_2()
777 .child(
778 div()
779 .w(px(16.))
780 .text_xs()
781 .text_color(rgb(text_color))
782 .child("H")
783 )
784 .child(
785 div()
786 .id("hue_slider")
787 .relative()
788 .flex_1()
789 .h(px(20.))
790 .rounded_sm()
791 .border_1()
792 .border_color(rgb(border_input))
793 .overflow_hidden()
794 .cursor_pointer()
795 .child(
796 canvas(
797 move |bounds, _window, _cx| {
798 hue_origin_for_paint.set(bounds.origin.x.into());
799 hue_width_for_paint.set(bounds.size.width.into());
800 bounds
801 },
802 move |bounds, _prepaint_result, window, _cx| {
803 // Paint rainbow gradient using multiple quads (90 degrees = left to right)
804 let width: f32 = bounds.size.width.into();
805 let segment_count = 6;
806 let segment_width = width / segment_count as f32;
807 let hue_colors = [
808 0xFF0000u32, // Red
809 0xFFFF00, // Yellow
810 0x00FF00, // Green
811 0x00FFFF, // Cyan
812 0x0000FF, // Blue
813 0xFF00FF, // Magenta
814 0xFF0000, // Red (wrap)
815 ];
816
817 for i in 0..segment_count {
818 let start_x = bounds.origin.x + px(i as f32 * segment_width);
819 let segment_bounds = Bounds {
820 origin: point(start_x, bounds.origin.y),
821 size: size(px(segment_width + 1.0), bounds.size.height),
822 };
823 let start_color = rgb(hue_colors[i]);
824 let end_color = rgb(hue_colors[i + 1]);
825 let start_stop = linear_color_stop(start_color, 0.0);
826 let end_stop = linear_color_stop(end_color, 1.0);
827 let gradient = linear_gradient(90.0, start_stop, end_stop);
828 window.paint_quad(fill(segment_bounds, gradient));
829 }
830 },
831 )
832 .size_full()
833 .absolute()
834 )
835 // Handle - use measured width, accounting for handle width
836 .child({
837 let measured_width = hue_width.get();
838 let handle_width = 4.0f32;
839 // Handle moves within (0, measured_width - handle_width)
840 // Use 359.0 as max to match the clamped hue range
841 let handle_x = (current_hsv.h / 359.0).min(1.0) * (measured_width - handle_width);
842 div()
843 .absolute()
844 .top_0()
845 .bottom_0()
846 .left(px(handle_x))
847 .w(px(handle_width))
848 .bg(rgb(theme.bg_white))
849 .border_1()
850 .border_color(rgb(theme.text_dark))
851 .rounded_sm()
852 })
853 .on_mouse_down(MouseButton::Left, {
854 let hue_origin = hue_origin_for_drag.clone();
855 let hue_width = hue_width_for_drag.clone();
856 cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
857 let x: f32 = event.position.x.into();
858 this.handle_hue_at_position(x, hue_origin.get(), hue_width.get(), cx);
859 })
860 })
861 .on_drag(
862 HueDrag {
863 origin: hue_origin_for_drag.clone(),
864 width: hue_width_for_drag.clone(),
865 },
866 |_drag, _offset, _window, cx| cx.new(|_| EmptyDragView),
867 )
868 .on_drag_move(cx.listener(move |this, event: &DragMoveEvent<HueDrag>, _window, cx| {
869 let x: f32 = event.event.position.x.into();
870 let drag = event.drag(cx);
871 this.handle_hue_at_position(x, drag.origin.get(), drag.width.get(), cx);
872 }))
873 )
874 )
875 // Alpha slider (if enabled)
876 .when(with_alpha, |parent| {
877 parent.child(
878 div()
879 .flex()
880 .flex_row()
881 .items_center()
882 .gap_2()
883 .child(
884 div()
885 .w(px(16.))
886 .text_xs()
887 .text_color(rgb(text_color))
888 .child("A")
889 )
890 .child(
891 div()
892 .id("alpha_slider")
893 .relative()
894 .flex_1()
895 .h(px(20.))
896 .rounded_sm()
897 .border_1()
898 .border_color(rgb(border_input))
899 .overflow_hidden()
900 .cursor_pointer()
901 // Checkerboard background for alpha visualization
902 .child(Self::render_checkerboard())
903 .child(
904 canvas(
905 move |bounds, _window, _cx| {
906 alpha_origin_for_paint.set(bounds.origin.x.into());
907 alpha_width_for_paint.set(bounds.size.width.into());
908 bounds
909 },
910 move |bounds, _prepaint_result, window, _cx| {
911 // Paint color gradient with transparency (90 degrees = left to right)
912 let color = rgb(current_rgb.to_u32());
913 let start_stop = linear_color_stop(transparent_white(), 0.0);
914 let end_stop = linear_color_stop(color, 1.0);
915 let gradient = linear_gradient(90.0, start_stop, end_stop);
916 window.paint_quad(fill(bounds, gradient));
917 },
918 )
919 .size_full()
920 .absolute()
921 )
922 // Handle - use measured width, accounting for handle width
923 .child({
924 let measured_width = alpha_width.get();
925 let handle_width = 4.0f32;
926 // Handle moves within (0, measured_width - handle_width)
927 let handle_x = (current_alpha as f32 / 255.0) * (measured_width - handle_width);
928 div()
929 .absolute()
930 .top_0()
931 .bottom_0()
932 .left(px(handle_x))
933 .w(px(handle_width))
934 .bg(rgb(theme.bg_white))
935 .border_1()
936 .border_color(rgb(theme.text_dark))
937 .rounded_sm()
938 })
939 .on_mouse_down(MouseButton::Left, {
940 let alpha_origin = alpha_origin_for_drag.clone();
941 let alpha_width = alpha_width_for_drag.clone();
942 cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
943 let x: f32 = event.position.x.into();
944 this.handle_alpha_at_position(x, alpha_origin.get(), alpha_width.get(), cx);
945 })
946 })
947 .on_drag(
948 AlphaDrag {
949 origin: alpha_origin_for_drag.clone(),
950 width: alpha_width_for_drag.clone(),
951 },
952 |_drag, _offset, _window, cx| cx.new(|_| EmptyDragView),
953 )
954 .on_drag_move(cx.listener(move |this, event: &DragMoveEvent<AlphaDrag>, _window, cx| {
955 let x: f32 = event.event.position.x.into();
956 let drag = event.drag(cx);
957 this.handle_alpha_at_position(x, drag.origin.get(), drag.width.get(), cx);
958 }))
959 )
960 )
961 })
962 // RGB sliders
963 .child(
964 Self::render_component_slider(
965 "R", current_rgb.r as f32, 255.0,
966 (0x000000, 0xFF0000),
967 |this, v, cx| this.update_from_rgb(v as u8, this.current_rgb.g, this.current_rgb.b, cx),
968 &theme, cx
969 )
970 )
971 .child(
972 Self::render_component_slider(
973 "G", current_rgb.g as f32, 255.0,
974 (0x000000, 0x00FF00),
975 |this, v, cx| this.update_from_rgb(this.current_rgb.r, v as u8, this.current_rgb.b, cx),
976 &theme, cx
977 )
978 )
979 .child(
980 Self::render_component_slider(
981 "B", current_rgb.b as f32, 255.0,
982 (0x000000, 0x0000FF),
983 |this, v, cx| this.update_from_rgb(this.current_rgb.r, this.current_rgb.g, v as u8, cx),
984 &theme, cx
985 )
986 )
987 // Old / New color comparison
988 .child(
989 div()
990 .flex()
991 .flex_row()
992 .items_center()
993 .gap_4()
994 .child(
995 div()
996 .flex()
997 .flex_col()
998 .items_center()
999 .gap_1()
1000 .child(
1001 div()
1002 .text_xs()
1003 .text_color(rgb(text_color))
1004 .child("Old")
1005 )
1006 .child(
1007 div()
1008 .id("old_color_swatch")
1009 .relative()
1010 .w(px(60.))
1011 .h(px(30.))
1012 .border_1()
1013 .border_color(rgb(border_input))
1014 .rounded_md()
1015 .overflow_hidden()
1016 .cursor_pointer()
1017 // Checkerboard for alpha
1018 .when(with_alpha, |d| d.child(Self::render_checkerboard()))
1019 .child(
1020 div()
1021 .size_full()
1022 .absolute()
1023 .bg(original_color)
1024 )
1025 .on_click(cx.listener(|this, _event, _window, cx| {
1026 this.set_value_internal(&this.original_value.clone(), cx);
1027 }))
1028 )
1029 .child(
1030 div()
1031 .text_xs()
1032 .text_color(rgb(text_color))
1033 .child(original_hex)
1034 )
1035 )
1036 .child(
1037 div()
1038 .flex()
1039 .flex_col()
1040 .items_center()
1041 .gap_1()
1042 .child(
1043 div()
1044 .text_xs()
1045 .text_color(rgb(text_color))
1046 .child("New")
1047 )
1048 .child(
1049 div()
1050 .relative()
1051 .w(px(60.))
1052 .h(px(30.))
1053 .border_1()
1054 .border_color(rgb(border_input))
1055 .rounded_md()
1056 .overflow_hidden()
1057 // Checkerboard for alpha
1058 .when(with_alpha, |d| d.child(Self::render_checkerboard()))
1059 .child(
1060 div()
1061 .size_full()
1062 .absolute()
1063 .bg(new_color)
1064 )
1065 )
1066 .child(
1067 div()
1068 .text_xs()
1069 .text_color(rgb(text_color))
1070 .child(new_hex)
1071 )
1072 )
1073 )
1074 // Cancel / Apply buttons
1075 .child(
1076 div()
1077 .flex()
1078 .flex_row()
1079 .justify_end()
1080 .gap_2()
1081 .mt_2()
1082 .child(
1083 secondary_button("picker_cancel", "Cancel", cx)
1084 .on_click(cx.listener(|this, _event, _window, cx| {
1085 this.cancel_picker(cx);
1086 }))
1087 )
1088 .child(
1089 primary_button("picker_apply", "Apply", true, cx)
1090 .on_click(cx.listener(|this, _event, _window, cx| {
1091 this.apply_picker(cx);
1092 }))
1093 )
1094 )
1095 // Apply on click outside
1096 .on_mouse_down_out(cx.listener(|this, _event, _window, cx| {
1097 this.apply_picker(cx);
1098 }))
1099 )
1100 )
1101 )
1102 })
1103 }
1104}
1105
1106impl ColorSwatch {
1107 /// Render a checkerboard pattern canvas for alpha transparency visualization
1108 ///
1109 /// Creates an 8x8 pixel alternating light/dark grid that shows through
1110 /// transparent colors, helping users visualize alpha values.
1111 fn render_checkerboard() -> impl IntoElement {
1112 canvas(
1113 |bounds, _window, _cx| bounds,
1114 |bounds, _prepaint_result, window, _cx| {
1115 let cell_size = 8.0f32;
1116 let light = rgb(0xFFFFFF);
1117 let dark = rgb(0xCCCCCC);
1118 let width: f32 = bounds.size.width.into();
1119 let height: f32 = bounds.size.height.into();
1120 // Use saturating conversion to prevent overflow on extremely large bounds
1121 let cols = ((width / cell_size).ceil() as i32).max(0);
1122 let rows = ((height / cell_size).ceil() as i32).max(0);
1123
1124 for row in 0..rows {
1125 for col in 0..cols {
1126 let is_light = (row + col) % 2 == 0;
1127 let color = if is_light { light } else { dark };
1128 let x = bounds.origin.x + px(col as f32 * cell_size);
1129 let y = bounds.origin.y + px(row as f32 * cell_size);
1130 let cell_w = (cell_size).min(width - col as f32 * cell_size);
1131 let cell_h = (cell_size).min(height - row as f32 * cell_size);
1132 let cell_bounds = Bounds {
1133 origin: point(x, y),
1134 size: size(px(cell_w), px(cell_h)),
1135 };
1136 window.paint_quad(fill(cell_bounds, color));
1137 }
1138 }
1139 },
1140 )
1141 .size_full()
1142 .absolute()
1143 }
1144
1145 /// Render a component slider for RGB values
1146 ///
1147 /// Creates a horizontal gradient slider with a draggable handle.
1148 /// Used for R, G, and B channel adjustment in the color picker.
1149 fn render_component_slider(
1150 label: &str,
1151 value: f32,
1152 max: f32,
1153 gradient_colors: (u32, u32),
1154 update_fn: fn(&mut ColorSwatch, f32, &mut Context<Self>),
1155 theme: &Theme,
1156 cx: &mut Context<Self>,
1157 ) -> impl IntoElement {
1158 let handle_content_width = 4.0f32;
1159 let handle_visual_width = 6.0f32; // 4px content + 2px border
1160 let value_display = value.round() as i32;
1161 let text_color = theme.text_primary;
1162 let border_input = theme.border_input;
1163 let handle_bg = theme.bg_white;
1164 let handle_border = theme.text_dark;
1165 let (start_color, end_color) = gradient_colors;
1166
1167 // Use Rc<Cell<f32>> for slider dimensions (like H slider)
1168 let slider_origin = Rc::new(Cell::new(0.0f32));
1169 let slider_width = Rc::new(Cell::new(200.0f32)); // Initial estimate
1170 let slider_origin_for_paint = slider_origin.clone();
1171 let slider_width_for_paint = slider_width.clone();
1172 let slider_origin_for_drag = slider_origin.clone();
1173 let slider_width_for_drag = slider_width.clone();
1174 let slider_width_for_handle = slider_width.clone();
1175 let slider_width_for_mouse = slider_width.clone();
1176
1177 div()
1178 .flex()
1179 .flex_row()
1180 .items_center()
1181 .gap_2()
1182 .child(
1183 div()
1184 .w(px(16.))
1185 .text_xs()
1186 .text_color(rgb(text_color))
1187 .child(label.to_string())
1188 )
1189 .child(
1190 div()
1191 .id(SharedString::from(format!("comp_slider_{}", label)))
1192 .relative()
1193 .flex_1()
1194 .h(px(20.))
1195 .rounded_sm()
1196 .border_1()
1197 .border_color(rgb(border_input))
1198 .overflow_hidden()
1199 .cursor_pointer()
1200 .child(
1201 canvas(
1202 move |bounds, _window, _cx| {
1203 slider_origin_for_paint.set(bounds.origin.x.into());
1204 slider_width_for_paint.set(bounds.size.width.into());
1205 bounds
1206 },
1207 move |bounds, _prepaint_result, window, _cx| {
1208 // 90 degrees = left to right
1209 let start_stop = linear_color_stop(rgb(start_color), 0.0);
1210 let end_stop = linear_color_stop(rgb(end_color), 1.0);
1211 let gradient = linear_gradient(90.0, start_stop, end_stop);
1212 window.paint_quad(fill(bounds, gradient));
1213 },
1214 )
1215 .size_full()
1216 .absolute()
1217 )
1218 // Handle - use measured width, accounting for handle width
1219 .child({
1220 let measured_width = slider_width_for_handle.get();
1221 let handle_width = 4.0f32;
1222 // Handle moves within (0, measured_width - handle_width)
1223 let handle_x = (value / max) * (measured_width - handle_width);
1224 div()
1225 .absolute()
1226 .top_0()
1227 .bottom_0()
1228 .left(px(handle_x))
1229 .w(px(handle_content_width))
1230 .bg(rgb(handle_bg))
1231 .border_1()
1232 .border_color(rgb(handle_border))
1233 .rounded_sm()
1234 })
1235 .on_mouse_down(MouseButton::Left, {
1236 let comp_origin = slider_origin_for_drag.clone();
1237 let comp_width = slider_width_for_mouse.clone();
1238 cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
1239 let x: f32 = event.position.x.into();
1240 let origin = comp_origin.get();
1241 let slider_w = comp_width.get();
1242 let usable_width = slider_w - handle_visual_width;
1243 if usable_width <= 0.0 {
1244 return;
1245 }
1246 let rel_x = (x - origin - handle_visual_width / 2.0).clamp(0.0, usable_width);
1247 let new_value = (rel_x / usable_width) * max;
1248 update_fn(this, new_value, cx);
1249 })
1250 })
1251 .on_drag(
1252 ComponentDrag {
1253 origin: slider_origin_for_drag.clone(),
1254 width: slider_width_for_drag.clone(),
1255 handle_visual_width,
1256 max_value: max,
1257 update_fn,
1258 },
1259 |_drag, _offset, _window, cx| cx.new(|_| EmptyDragView),
1260 )
1261 .on_drag_move(cx.listener(move |this, event: &DragMoveEvent<ComponentDrag>, _window, cx| {
1262 let x: f32 = event.event.position.x.into();
1263 let drag = event.drag(cx);
1264 let origin = drag.origin.get();
1265 let slider_w = drag.width.get();
1266 let usable_width = slider_w - drag.handle_visual_width;
1267 // Guard against division by zero
1268 if usable_width <= 0.0 {
1269 return;
1270 }
1271 // Map click position to handle left edge position, then to value
1272 let rel_x = (x - origin - drag.handle_visual_width / 2.0).clamp(0.0, usable_width);
1273 let new_value = (rel_x / usable_width) * drag.max_value;
1274 (drag.update_fn)(this, new_value, cx);
1275 }))
1276 )
1277 .child(
1278 div()
1279 .w(px(28.))
1280 .flex_shrink_0()
1281 .text_xs()
1282 .text_color(rgb(text_color))
1283 .text_right()
1284 .child(format!("{}", value_display))
1285 )
1286 }
1287}