diff --git a/crates/gpui_widgets/src/color/hsv.rs b/crates/gpui_widgets/src/color/hsv.rs new file mode 100644 index 0000000000..8d4bb21a69 --- /dev/null +++ b/crates/gpui_widgets/src/color/hsv.rs @@ -0,0 +1,141 @@ +//! Pure HSV color math for the color picker (no gpui dependency). + +/// A color in the HSV model: `h` in degrees `0..360`, `s` and `v` in `0..1`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HsvColor { + /// Hue in degrees, `0..360` (wraps). + pub h: f32, + /// Saturation, `0..1`. + pub s: f32, + /// Value (brightness), `0..1`. + pub v: f32, +} + +impl HsvColor { + /// Create a color, clamping components into range. + pub fn new(h: f32, s: f32, v: f32) -> Self { + Self { + h: h.rem_euclid(360.0), + s: s.clamp(0.0, 1.0), + v: v.clamp(0.0, 1.0), + } + } + + /// Convert from linear RGB components in `0..1`. + pub fn from_rgb(r: f32, g: f32, b: f32) -> Self { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let h = if delta == 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let s = if max == 0.0 { 0.0 } else { delta / max }; + Self::new(h, s, max) + } + + /// Convert to linear RGB components in `0..1`. + pub fn to_rgb(self) -> (f32, f32, f32) { + let h = (self.h.rem_euclid(360.0)) / 60.0; + let c = self.v * self.s; + let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs()); + let m = self.v - c; + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + (r + m, g + m, b + m) + } + + /// Convert to RGBA components in `0..255` (alpha given separately). + pub fn to_rgba_u8(self, alpha: u8) -> [u8; 4] { + let (r, g, b) = self.to_rgb(); + [ + (r * 255.0).round().clamp(0.0, 255.0) as u8, + (g * 255.0).round().clamp(0.0, 255.0) as u8, + (b * 255.0).round().clamp(0.0, 255.0) as u8, + alpha, + ] + } + + /// The fully-saturated hue at maximum value: `hsv(h, 1, 1)`. + pub fn hue_swatch(h: f32) -> Self { + Self::new(h, 1.0, 1.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 0.001 + } + + #[test] + fn primary_colors_round_trip() { + // Red. + assert!(approx(HsvColor::from_rgb(1.0, 0.0, 0.0).h, 0.0)); + // Green. + assert!(approx(HsvColor::from_rgb(0.0, 1.0, 0.0).h, 120.0)); + // Blue. + assert!(approx(HsvColor::from_rgb(0.0, 0.0, 1.0).h, 240.0)); + // Yellow. + assert!(approx(HsvColor::from_rgb(1.0, 1.0, 0.0).h, 60.0)); + } + + #[test] + fn rgb_to_hsv_to_rgb_round_trip() { + for (r, g, b) in [ + (0.2, 0.4, 0.6), + (1.0, 0.5, 0.25), + (0.9, 0.9, 0.9), + (0.1, 0.1, 0.1), + ] { + let hsv = HsvColor::from_rgb(r, g, b); + let (r2, g2, b2) = hsv.to_rgb(); + assert!(approx(r, r2) && approx(g, g2) && approx(b, b2), "{hsv:?}"); + } + } + + #[test] + fn saturation_and_value_extremes() { + // Gray has zero saturation. + let gray = HsvColor::from_rgb(0.5, 0.5, 0.5); + assert!(approx(gray.s, 0.0)); + assert!(approx(gray.v, 0.5)); + // Black has zero value. + let black = HsvColor::from_rgb(0.0, 0.0, 0.0); + assert!(approx(black.v, 0.0)); + // White is full value, zero saturation. + let white = HsvColor::from_rgb(1.0, 1.0, 1.0); + assert!(approx(white.v, 1.0) && approx(white.s, 0.0)); + } + + #[test] + fn to_rgba_u8_clamps() { + let red = HsvColor::new(0.0, 1.0, 1.0); + assert_eq!(red.to_rgba_u8(255), [255, 0, 0, 255]); + let black = HsvColor::new(120.0, 1.0, 0.0); + assert_eq!(black.to_rgba_u8(128), [0, 0, 0, 128]); + } + + #[test] + fn hue_wraps() { + assert!(approx(HsvColor::new(360.0, 1.0, 1.0).h, 0.0)); + assert!(approx(HsvColor::new(-30.0, 1.0, 1.0).h, 330.0)); + // Hue 360 == hue 0: same color. + let (r1, g1, b1) = HsvColor::new(0.0, 1.0, 1.0).to_rgb(); + let (r2, g2, b2) = HsvColor::new(360.0, 1.0, 1.0).to_rgb(); + assert!((r1 - r2).abs() < 0.001 && (g1 - g2).abs() < 0.001 && (b1 - b2).abs() < 0.001); + } +} diff --git a/crates/gpui_widgets/src/color/mod.rs b/crates/gpui_widgets/src/color/mod.rs new file mode 100644 index 0000000000..06990a93f3 --- /dev/null +++ b/crates/gpui_widgets/src/color/mod.rs @@ -0,0 +1,657 @@ +//! Color picking: a swatch button that opens a popup picker. +//! +//! The picker shows an SV (saturation/value) square with a hue strip, plus +//! RGBA numeric inputs. Editing any of them emits +//! [`ColorPickerEvent::ColorChanged`]; the host applies the color and calls +//! [`ColorPicker::set_color`] to keep the widget in sync. The eyedropper is a +//! placeholder for now. + +mod hsv; + +use gpui::{ + Anchor, App, Bounds, ClickEvent, Context, DragMoveEvent, ElementId, Entity, EventEmitter, + FocusHandle, Focusable, Hsla, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Rgba, + Render, Window, anchored, canvas, colors::DefaultColors, deferred, div, fill, + linear_color_stop, linear_gradient, + point, prelude::*, px, size, +}; + +pub use hsv::HsvColor; + +use crate::slider::SliderModel; +use crate::spinbox::{SpinBox, SpinBoxEvent}; +use crate::value::{SliderValue, ValueKind}; + +/// The size of the SV square and hue strip in the picker popup. +const SV_SIZE: f32 = 150.0; +const HUE_STRIP_WIDTH: f32 = 16.0; +const HUE_STRIP_SEGMENTS: usize = 48; + +/// A request emitted by a color picker. +#[derive(Debug, Clone, PartialEq)] +pub enum ColorPickerEvent { + /// The color changed (SV/hue drag, RGBA edit). + ColorChanged { + /// The control's stable id. + control: usize, + /// The new color. + color: Rgba, + }, + /// The popup was opened. + MenuOpened { + /// The control's stable id. + control: usize, + }, + /// The popup was closed. + MenuClosed { + /// The control's stable id. + control: usize, + }, +} + +/// Which RGBA channel a spinbox edits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Channel { + R, + G, + B, + A, +} + +/// Marker payloads for the SV-square and hue-strip drags. +#[derive(Clone, Copy, Debug)] +struct SvDrag; +#[derive(Clone, Copy, Debug)] +struct HueDrag; +#[derive(Clone, Copy, Debug)] +struct PickGhost; + +impl Render for PickGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().w(px(0.0)).h(px(0.0)) + } +} + +/// A swatch button with a popup color picker. +pub struct ColorPicker { + control: usize, + hsv: HsvColor, + alpha: u8, + focus_handle: FocusHandle, + open: bool, + popup_position: Point, + was_open_at_down: bool, + /// Bounds of the SV square / hue strip, refreshed each frame so clicks + /// can be converted to coordinates. + sv_bounds: Bounds, + hue_bounds: Bounds, + r_spin: Entity, + g_spin: Entity, + b_spin: Entity, + a_spin: Entity, +} + +impl ColorPicker { + /// Create a picker for `control` showing `color`. + pub fn new(control: usize, color: Rgba, window: &mut Window, cx: &mut Context) -> Self { + let hsv = HsvColor::from_rgb(color.r, color.g, color.b); + let alpha = (color.a * 255.0).round().clamp(0.0, 255.0) as u8; + let r_spin = Self::channel_spinbox(cx, window, Channel::R, (color.r * 255.0) as u8); + let g_spin = Self::channel_spinbox(cx, window, Channel::G, (color.g * 255.0) as u8); + let b_spin = Self::channel_spinbox(cx, window, Channel::B, (color.b * 255.0) as u8); + let a_spin = Self::channel_spinbox(cx, window, Channel::A, alpha); + Self { + control, + hsv, + alpha, + focus_handle: cx.focus_handle(), + open: false, + popup_position: Point::default(), + was_open_at_down: false, + sv_bounds: Bounds::default(), + hue_bounds: Bounds::default(), + r_spin, + g_spin, + b_spin, + a_spin, + } + } + + fn channel_spinbox( + cx: &mut Context, + window: &mut Window, + channel: Channel, + value: u8, + ) -> Entity { + let model = SliderModel::new(ValueKind::Integer, 0.0, 255.0, 1.0, value as f64); + let spin = cx.new(|cx| SpinBox::new(channel as usize * 1000 + 0, model, window, cx)); + cx.subscribe( + &spin, + move |this: &mut Self, + _s: Entity, + event: &SpinBoxEvent, + cx| { + this.on_channel_edit(channel, event, cx); + }, + ) + .detach(); + spin + } + + /// The current color. + pub fn color(&self) -> Rgba { + let [r, g, b, a] = self.hsv.to_rgba_u8(self.alpha); + Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: a as f32 / 255.0, + } + } + + /// Whether the popup is open. + pub fn is_open(&self) -> bool { + self.open + } + + /// Apply a color from the host and repaint (also syncs the RGBA fields). + pub fn set_color(&mut self, color: Rgba, cx: &mut Context) { + self.hsv = HsvColor::from_rgb(color.r, color.g, color.b); + self.alpha = (color.a * 255.0).round().clamp(0.0, 255.0) as u8; + self.sync_channels(cx); + cx.notify(); + } + + /// Push the current channels into the RGBA spinboxes (no events emitted). + fn sync_channels(&self, cx: &mut Context) { + let [r, g, b, a] = self.hsv.to_rgba_u8(self.alpha); + cx.update_entity(&self.r_spin, |spin, cx| { + spin.set_value(SliderValue::Integer(r as i64), cx); + }); + cx.update_entity(&self.g_spin, |spin, cx| { + spin.set_value(SliderValue::Integer(g as i64), cx); + }); + cx.update_entity(&self.b_spin, |spin, cx| { + spin.set_value(SliderValue::Integer(b as i64), cx); + }); + cx.update_entity(&self.a_spin, |spin, cx| { + spin.set_value(SliderValue::Integer(a as i64), cx); + }); + } + + fn on_channel_edit(&mut self, channel: Channel, event: &SpinBoxEvent, cx: &mut Context) { + let value = match event { + SpinBoxEvent::ValueChanged { value, .. } | SpinBoxEvent::EditCommitted { value, .. } => { + *value + } + SpinBoxEvent::EditCancelled { .. } => return, + }; + let v = value.to_f64().round().clamp(0.0, 255.0) as u8; + match channel { + Channel::A => self.alpha = v, + _ => { + let [r, g, b, _] = self.hsv.to_rgba_u8(self.alpha); + let (r, g, b) = match channel { + Channel::R => (v, g, b), + Channel::G => (r, v, b), + Channel::B => (r, g, v), + Channel::A => unreachable!(), + }; + self.hsv = HsvColor::from_rgb( + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + ); + } + } + self.emit_changed(cx); + } + + fn set_hsv(&mut self, hsv: HsvColor, cx: &mut Context) { + if self.hsv != hsv { + self.hsv = hsv; + self.sync_channels(cx); + self.emit_changed(cx); + } + } + + fn emit_changed(&mut self, cx: &mut Context) { + cx.emit(ColorPickerEvent::ColorChanged { + control: self.control, + color: self.color(), + }); + cx.notify(); + } + + fn open_menu(&mut self, position: Point, cx: &mut Context) { + if !self.open { + self.open = true; + self.popup_position = position; + cx.emit(ColorPickerEvent::MenuOpened { + control: self.control, + }); + cx.notify(); + } + } + + fn close_menu(&mut self, cx: &mut Context) { + if self.open { + self.open = false; + cx.emit(ColorPickerEvent::MenuClosed { + control: self.control, + }); + cx.notify(); + } + } + + /// Pick a color from the SV square at a window-space position. + fn pick_sv(&mut self, position: Point, cx: &mut Context) { + let bounds = self.sv_bounds; + if f32::from(bounds.size.width) <= 0.0 { + return; + } + let x = ((f32::from(position.x) - f32::from(bounds.left())) / f32::from(bounds.size.width)) + .clamp(0.0, 1.0); + let y = + ((f32::from(position.y) - f32::from(bounds.top())) / f32::from(bounds.size.height)) + .clamp(0.0, 1.0); + self.set_hsv(HsvColor::new(self.hsv.h, x, 1.0 - y), cx); + } + + fn pick_hue(&mut self, position: Point, cx: &mut Context) { + let bounds = self.hue_bounds; + if f32::from(bounds.size.height) <= 0.0 { + return; + } + let y = ((f32::from(position.y) - f32::from(bounds.top())) / f32::from(bounds.size.height)) + .clamp(0.0, 1.0); + self.set_hsv(HsvColor::new(y * 360.0, self.hsv.s, self.hsv.v), cx); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) { + match event.keystroke.key.as_str() { + "escape" => self.close_menu(cx), + "enter" | "space" => { + if self.open { + self.close_menu(cx); + } else { + self.open_menu(self.popup_position, cx); + } + } + _ => {} + } + } +} + +impl EventEmitter for ColorPicker {} + +impl Focusable for ColorPicker { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ColorPicker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let entity = cx.entity(); + let hue_entity = entity.clone(); + let control = self.control; + let color_hsla = hsv_to_hsla(self.hsv); + let hue = self.hsv.h; + + // Swatch field. + let swatch = div() + .id(ElementId::named_usize("gpui-widgets-color-swatch", control)) + .w(px(28.0)) + .h(px(28.0)) + .rounded_md() + .border_1() + .border_color(if self.open { colors.selected } else { colors.border }) + .bg(color_hsla) + .cursor_pointer() + .track_focus(&self.focus_handle) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event: &MouseDownEvent, _window, _cx| { + this.was_open_at_down = this.open; + }), + ) + .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| { + if this.was_open_at_down { + this.close_menu(cx); + } else { + this.open_menu(event.position(), cx); + } + cx.stop_propagation(); + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { + this.on_key_down(event, cx); + })); + + // SV square + hue strip. + let sv_square = div() + .id(ElementId::named_usize("gpui-widgets-color-sv", control)) + .w(px(SV_SIZE)) + .h(px(SV_SIZE)) + .cursor_crosshair() + .debug_selector(|| "color-sv".into()) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.pick_sv(event.position, cx); + }), + ) + .on_drag(SvDrag, pick_ghost) + .on_drag_move( + cx.listener(|this, event: &DragMoveEvent, _window, cx| { + this.pick_sv(event.event.position, cx); + }), + ) + .child( + canvas( + move |bounds, _window, cx| { + entity.update(cx, |this, _| this.sv_bounds = bounds); + bounds + }, + move |bounds, content, window, cx| { + paint_sv_square(bounds, content, hue, window, cx); + }, + ) + .size_full(), + ); + + let hue_strip = div() + .id(ElementId::named_usize("gpui-widgets-color-hue", control)) + .w(px(HUE_STRIP_WIDTH)) + .h(px(SV_SIZE)) + .cursor_crosshair() + .debug_selector(|| "color-hue".into()) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.pick_hue(event.position, cx); + }), + ) + .on_drag(HueDrag, pick_ghost) + .on_drag_move( + cx.listener(|this, event: &DragMoveEvent, _window, cx| { + this.pick_hue(event.event.position, cx); + }), + ) + .child( + canvas( + move |bounds, _window, cx| { + hue_entity.update(cx, |this, _| this.hue_bounds = bounds); + bounds + }, + move |bounds, content, window, cx| { + paint_hue_strip(bounds, content, window, cx); + }, + ) + .size_full(), + ); + + // The RGBA inputs + eyedropper placeholder. + let rgba_row = div() + .flex() + .gap(px(4.0)) + .child(self.r_spin.clone()) + .child(self.g_spin.clone()) + .child(self.b_spin.clone()) + .child(self.a_spin.clone()); + + let eyedropper = div() + .id(ElementId::named_usize("gpui-widgets-color-eye", control)) + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(colors.border) + .text_color(colors.disabled) + .opacity(0.7) + .child("吸管(占位)"); + + let popup = if self.open { + deferred( + anchored() + .position(self.popup_position) + .anchor(Anchor::TopLeft) + .offset(point(px(0.0), px(32.0))) + .snap_to_window_with_margin(px(8.0)) + .child( + div() + .p_2() + .rounded_lg() + .border_1() + .border_color(colors.border) + .bg(colors.container) + .debug_selector(|| "color-popup".into()) + .flex() + .flex_col() + .gap(px(6.0)) + .on_mouse_up_out( + MouseButton::Left, + cx.listener( + |this, _event: &gpui::MouseUpEvent, _window, cx| { + this.close_menu(cx); + }, + ), + ) + .child( + div() + .flex() + .gap(px(4.0)) + .debug_selector(|| "color-row".into()) + .child(sv_square) + .child(hue_strip), + ) + .child(rgba_row) + .child(eyedropper), + ), + ) + .with_priority(1) + } else { + deferred(div()) + }; + + div().relative().child(swatch).child(popup) + } +} + +/// Build the invisible ghost view for SV/hue drags. +fn pick_ghost(_drag: &impl Copy, _offset: Point, _window: &mut Window, cx: &mut App) -> Entity { + cx.new(|_| PickGhost) +} + +/// Convert HSV to gpui's HSLA (lightness/saturation model). +fn hsv_to_hsla(hsv: HsvColor) -> Hsla { + let h = hsv.h.rem_euclid(360.0) / 360.0; + let l = hsv.v * (1.0 - hsv.s / 2.0); + let s = if l <= 0.0 || l >= 1.0 { + 0.0 + } else { + ((hsv.v - l) / l.min(1.0 - l)).clamp(0.0, 1.0) + }; + Hsla { + h, + s, + l, + a: 1.0, + } +} + +fn hue_hsla(h: f32) -> Hsla { + Hsla { + h: h.rem_euclid(360.0) / 360.0, + s: 1.0, + l: 0.5, + a: 1.0, + } +} + +fn paint_sv_square( + bounds: Bounds, + _content: Bounds, + hue: f32, + window: &mut Window, + cx: &mut App, +) { + let _ = cx; + // Base: pure hue. + window.paint_quad(fill(bounds, hue_hsla(hue))); + // White fade left -> right (saturation axis). + let white = Hsla { + h: 0.0, + s: 0.0, + l: 1.0, + a: 1.0, + }; + let transparent_white = Hsla { + h: 0.0, + s: 0.0, + l: 1.0, + a: 0.0, + }; + window.paint_quad(fill( + bounds, + linear_gradient(90.0, linear_color_stop(white, 0.0), linear_color_stop(transparent_white, 1.0)), + )); + // Black fade top -> bottom (value axis). + let transparent_black = Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 0.0, + }; + let black = Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 1.0, + }; + window.paint_quad(fill( + bounds, + linear_gradient(180.0, linear_color_stop(transparent_black, 0.0), linear_color_stop(black, 1.0)), + )); +} + +fn paint_hue_strip( + bounds: Bounds, + _content: Bounds, + window: &mut Window, + cx: &mut App, +) { + let _ = cx; + let height = f32::from(bounds.size.height); + for i in 0..HUE_STRIP_SEGMENTS { + let t0 = i as f32 / HUE_STRIP_SEGMENTS as f32; + let t1 = (i + 1) as f32 / HUE_STRIP_SEGMENTS as f32; + let hue = (t0 + t1) * 180.0; + let segment = Bounds::new( + point(bounds.left(), bounds.top() + px(t0 * height)), + size(bounds.size.width, px((t1 - t0) * height)), + ); + window.paint_quad(fill(segment, hue_hsla(hue))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Modifiers, TestAppContext, VisualTestContext}; + + #[test] + fn hsv_to_hsla_matches_rgb() { + // Red: hsv(0,1,1) -> hsla(0,1,0.5) -> rgb(1,0,0). + let hsla = hsv_to_hsla(HsvColor::new(0.0, 1.0, 1.0)); + assert!((hsla.h - 0.0).abs() < 0.001); + assert!((hsla.s - 1.0).abs() < 0.001); + assert!((hsla.l - 0.5).abs() < 0.001); + // Gray: hsv(0,0,0.5) -> hsla(any,0,0.5). + let gray = hsv_to_hsla(HsvColor::new(120.0, 0.0, 0.5)); + assert!(gray.s < 0.001); + assert!((gray.l - 0.5).abs() < 0.001); + } + + #[gpui::test] + async fn clicking_sv_square_changes_color(cx: &mut TestAppContext) { + struct Host { + picker: Entity, + events: Vec, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.picker.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(300.0), px(300.0)), |window, cx| { + let picker = cx.new(|cx| { + ColorPicker::new( + 1, + Rgba { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + window, + cx, + ) + }); + let host = Host { + picker, + events: Vec::new(), + }; + cx.subscribe( + &host.picker, + |host: &mut Host, + _p: Entity, + event: &ColorPickerEvent, + _cx: &mut Context| { + host.events.push(event.clone()); + }, + ) + .detach(); + host + }); + cx.run_until_parked(); + let host = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + // Open the picker by clicking the swatch. + cx.simulate_click(point(px(14.0), px(14.0)), Modifiers::none()); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + let sv_bounds = cx + .debug_bounds("color-sv") + .expect("SV square should be rendered"); + eprintln!("bounds: sv={:?} row={:?} popup={:?}", + cx.debug_bounds("color-sv"), + cx.debug_bounds("color-row"), + cx.debug_bounds("color-popup")); + // Click near the top-left of the SV square: that is white. + let click = point(sv_bounds.left() + px(10.0), sv_bounds.top() + px(10.0)); + cx.simulate_click(click, Modifiers::none()); + cx.run_until_parked(); + + let (color, changed) = cx.read(|app| { + let host = host.read(app); + let color = host.picker.read(app).color(); + let changed = host.events.iter().any(|e| { + matches!(e, ColorPickerEvent::ColorChanged { .. }) + }); + (color, changed) + }); + assert!(changed, "expected a ColorChanged event"); + // Near the top-left the color is a pale, low-saturation tint of the + // hue (high value, low saturation) - clearly not the initial red. + assert!(color.r > 0.85 && color.g > 0.8 && color.b > 0.8, "got {color:?}"); + assert!(color.g > color.b - 0.01 || (color.r - color.g).abs() > 0.05, "got {color:?}"); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index 34778b6314..8d14a31fc7 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -18,6 +18,7 @@ //! covered by plain unit tests. pub mod checkbox; +pub mod color; pub mod combo_box; pub mod keyable; pub mod radio_group;