diff --git a/Cargo.lock b/Cargo.lock index 1a70e4b6..909c2410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5415,6 +5415,7 @@ dependencies = [ "chrono", "gpui", "image", + "itertools 0.13.0", "once_cell", "paste", "regex", diff --git a/README.md b/README.md index 82fdb9e2..8971fdc8 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Calendar - [ ] TimePicker - [x] DateRangePicker -- [ ] ColorPicker +- [x] ColorPicker - [x] List - [x] A complex List example. - [x] Table diff --git a/assets/icons/palette.svg b/assets/icons/palette.svg new file mode 100644 index 00000000..b50674d3 --- /dev/null +++ b/assets/icons/palette.svg @@ -0,0 +1 @@ + diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index e58c9488..2501ab9a 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -7,9 +7,11 @@ use gpui::{ use ui::{ button::Button, checkbox::Checkbox, + color_picker::{ColorPicker, ColorPickerEvent}, h_flex, input::{InputEvent, OtpInput, TextInput}, prelude::FluentBuilder as _, + theme::{ActiveTheme, Colorize, Theme}, v_flex, FocusableCycle, IconName, Sizable, }; @@ -42,6 +44,7 @@ pub struct InputStory { otp_input_small: View, otp_input_large: View, opt_input_sized: View, + color_picker: View, } impl InputStory { @@ -103,6 +106,23 @@ impl InputStory { }) .detach(); + let color_picker = cx.new_view(|cx| { + let picker = ColorPicker::new("picker1", cx); + picker + }); + cx.subscribe(&color_picker, |_, _, ev: &ColorPickerEvent, cx| match ev { + ColorPickerEvent::Change(color) => { + if let Some(color) = color { + let theme = cx.global_mut::(); + theme.primary = *color; + theme.primary_hover = color.lighten(0.1); + theme.primary_active = color.darken(0.1); + cx.refresh(); + } + } + }) + .detach(); + Self { input1, input2, @@ -147,6 +167,7 @@ impl InputStory { .default_value("654321") .with_size(px(55.)) }), + color_picker, } } @@ -197,6 +218,7 @@ impl FocusableCycle for InputStory { self.suffix_input1.focus_handle(cx), self.large_input.focus_handle(cx), self.small_input.focus_handle(cx), + self.color_picker.focus_handle(cx), self.otp_input.focus_handle(cx), ] .to_vec() @@ -244,6 +266,7 @@ impl Render for InputStory { .child(self.small_input.clone()), ), ) + .child(section("Color Picker", cx).child(self.color_picker.clone())) .child( section( h_flex() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index f5db24fd..b8a1ea31 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -11,6 +11,7 @@ doctest = false [dependencies] gpui.workspace = true anyhow = "1" +itertools = "0.13.0" serde = "1.0.203" serde_json = "1" diff --git a/crates/ui/src/color_picker.rs b/crates/ui/src/color_picker.rs new file mode 100644 index 00000000..aefdfec9 --- /dev/null +++ b/crates/ui/src/color_picker.rs @@ -0,0 +1,305 @@ +use gpui::{ + anchored, deferred, div, prelude::FluentBuilder as _, px, AppContext, ElementId, EventEmitter, + FocusHandle, FocusableView, Hsla, InteractiveElement as _, IntoElement, KeyBinding, Length, + MouseButton, ParentElement, Render, SharedString, StatefulInteractiveElement as _, Styled, + ViewContext, +}; + +use crate::{ + colors::DEFAULT_COLOR, + divider::Divider, + h_flex, + input::ClearButton, + popover::Escape, + theme::{ActiveTheme as _, Colorize}, + v_flex, ColorExt as _, Icon, IconName, Size, StyleSized as _, StyledExt as _, +}; + +pub fn init(cx: &mut AppContext) { + let context = Some("ColorPicker"); + cx.bind_keys([KeyBinding::new("escape", Escape, context)]) +} + +#[derive(Clone)] +pub enum ColorPickerEvent { + Change(Option), +} + +fn color_palettes() -> Vec> { + use itertools::Itertools as _; + + macro_rules! c { + ($color:tt) => { + DEFAULT_COLOR + .$color + .keys() + .sorted() + .map(|k| DEFAULT_COLOR.$color.get(k).map(|c| c.hsla).unwrap()) + .collect::>() + }; + } + + vec![ + c!(stone), + c!(red), + c!(orange), + c!(yellow), + c!(green), + c!(cyan), + c!(blue), + c!(purple), + c!(pink), + ] +} + +pub struct ColorPicker { + id: ElementId, + focus_handle: FocusHandle, + featured_colors: Vec, + value: Option, + cleanable: bool, + open: bool, + size: Size, + width: Length, + hovered_color: Option, +} + +impl ColorPicker { + pub fn new(id: impl Into, cx: &mut ViewContext) -> Self { + Self { + id: id.into(), + focus_handle: cx.focus_handle(), + featured_colors: vec![ + crate::black(), + crate::white(), + crate::red_600(), + crate::orange_600(), + crate::yellow_600(), + crate::green_600(), + crate::blue_600(), + crate::indigo_600(), + crate::purple_600(), + ], + value: None, + cleanable: false, + open: false, + size: Size::default(), + width: Length::Auto, + hovered_color: None, + } + } + + /// Set true to show the clear button when the input field is not empty. + pub fn cleanable(mut self) -> Self { + self.cleanable = true; + self + } + + /// Set width of the date picker input field, default is `Length::Auto`. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + pub fn featured_colors(mut self, colors: Vec) -> Self { + self.featured_colors = colors; + self + } + + pub fn value(mut self, value: Hsla) -> Self { + self.value = Some(value); + self + } + + fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { + self.open = false; + cx.notify(); + } + + fn clean(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext) { + self.update_value(None, cx) + } + + fn toggle_picker(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext) { + self.open = !self.open; + cx.notify(); + } + + fn update_value(&mut self, value: Option, cx: &mut ViewContext) { + self.value = value; + cx.emit(ColorPickerEvent::Change(value)); + cx.notify(); + } + + fn render_item( + &self, + color: Hsla, + clickable: bool, + cx: &mut ViewContext, + ) -> impl IntoElement { + div() + .id(SharedString::from(format!( + "color-{}", + color.to_hex_string() + ))) + .h_5() + .w_5() + .bg(color) + .rounded_sm() + .border_1() + .border_color(color.darken(0.1)) + .when(clickable, |this| { + this.cursor_pointer() + .hover(|this| this.border_color(color.darken(0.3))) + .on_mouse_move(cx.listener(move |view, _, cx| { + view.hovered_color = Some(color); + cx.notify(); + })) + .on_click(cx.listener(move |view, _, cx| { + view.update_value(Some(color), cx); + view.open = false; + cx.notify(); + })) + }) + } + + fn render_colors(&self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .gap_2() + .child( + h_flex().gap_1().children( + self.featured_colors + .iter() + .map(|color| self.render_item(*color, true, cx)), + ), + ) + .child( + v_flex() + .gap_1() + .children(color_palettes().iter().map(|sub_colors| { + h_flex().gap_1().children( + sub_colors + .iter() + .rev() + .map(|color| self.render_item(*color, true, cx)), + ) + })), + ) + .when_some(self.hovered_color.clone(), |this, hovered_color| { + this.child(Divider::horizontal()).child( + h_flex() + .gap_1() + .items_center() + .child( + div() + .bg(hovered_color) + .size_5() + .rounded(px(cx.theme().radius)), + ) + .child(hovered_color.to_hex_string()), + ) + }) + } +} + +impl EventEmitter for ColorPicker {} +impl FocusableView for ColorPicker { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ColorPicker { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let is_focused = self.focus_handle.is_focused(cx); + let show_clean = self.cleanable && self.value.is_some(); + + let display_title = if let Some(value) = self.value { + format!("{}", value.to_hex_string()) + } else { + "Select a color".to_string() + }; + + let value = self.value.unwrap_or_else(|| cx.theme().foreground); + + div() + .id(self.id.clone()) + .key_context("ColorPicker") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::escape)) + .w_full() + .relative() + .map(|this| match self.width { + Length::Definite(l) => this.flex_none().w(l), + Length::Auto => this.w_full(), + }) + .input_text_size(self.size) + .child( + div() + .id("color-picker-input") + .relative() + .flex() + .items_center() + .justify_between() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().input) + .rounded(px(cx.theme().radius)) + .shadow_sm() + .cursor_pointer() + .overflow_hidden() + .input_text_size(self.size) + .when(is_focused, |this| this.outline(cx)) + .input_size(self.size) + .when(!self.open, |this| { + this.on_click(cx.listener(Self::toggle_picker)) + }) + .child( + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_1() + .child(self.render_item(value, false, cx)) + .child(div().flex_1().overflow_hidden().child(display_title)) + .when(show_clean, |this| { + this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean))) + }) + .when(!show_clean, |this| { + this.child( + Icon::new(IconName::Palette) + .text_color(cx.theme().muted_foreground), + ) + }), + ), + ) + .when(self.open, |this| { + this.child( + deferred( + anchored().snap_to_window().child( + div() + .track_focus(&self.focus_handle) + .occlude() + .absolute() + .mt_1p5() + .w_72() + .overflow_hidden() + .rounded_lg() + .p_3() + .border_1() + .border_color(cx.theme().border) + .shadow_lg() + .rounded_lg() + .bg(cx.theme().background) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|view, _, cx| view.escape(&Escape, cx)), + ) + .child(self.render_colors(cx)), + ), + ) + .with_priority(2), + ) + }) + } +} diff --git a/crates/ui/src/colors.rs b/crates/ui/src/colors.rs index 8cce7d84..d1307b5c 100644 --- a/crates/ui/src/colors.rs +++ b/crates/ui/src/colors.rs @@ -6,10 +6,38 @@ use serde::{de::Error, Deserialize, Deserializer}; use crate::theme::hsl; use anyhow::Result; -static DEFAULT_COLOR: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { - serde_json::from_str(include_str!("../default-colors.json")) - .expect("failed to parse default-json") -}); +pub trait ColorExt { + fn to_hex_string(&self) -> String; +} + +impl ColorExt for Hsla { + fn to_hex_string(&self) -> String { + let rgb = self.to_rgb(); + + if rgb.a < 1. { + return format!( + "#{:02X}{:02X}{:02X}{:02X}", + u32::from((rgb.r * 255.) as u32), + u32::from((rgb.g * 255.) as u32), + u32::from((rgb.b * 255.) as u32), + u32::from((self.a * 255.) as u32) + ); + } + + format!( + "#{:02X}{:02X}{:02X}", + u32::from((rgb.r * 255.) as u32), + u32::from((rgb.g * 255.) as u32), + u32::from((rgb.b * 255.) as u32) + ) + } +} + +pub(crate) static DEFAULT_COLOR: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + serde_json::from_str(include_str!("../default-colors.json")) + .expect("failed to parse default-json") + }); type ColorScales = HashMap; @@ -33,61 +61,61 @@ mod color_scales { } #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -struct ShacnColors { - black: ShacnColor, - white: ShacnColor, +pub(crate) struct ShacnColors { + pub(crate) black: ShacnColor, + pub(crate) white: ShacnColor, #[serde(with = "color_scales")] - slate: ColorScales, + pub(crate) slate: ColorScales, #[serde(with = "color_scales")] - gray: ColorScales, + pub(crate) gray: ColorScales, #[serde(with = "color_scales")] - zinc: ColorScales, + pub(crate) zinc: ColorScales, #[serde(with = "color_scales")] - neutral: ColorScales, + pub(crate) neutral: ColorScales, #[serde(with = "color_scales")] - stone: ColorScales, + pub(crate) stone: ColorScales, #[serde(with = "color_scales")] - red: ColorScales, + pub(crate) red: ColorScales, #[serde(with = "color_scales")] - orange: ColorScales, + pub(crate) orange: ColorScales, #[serde(with = "color_scales")] - amber: ColorScales, + pub(crate) amber: ColorScales, #[serde(with = "color_scales")] - yellow: ColorScales, + pub(crate) yellow: ColorScales, #[serde(with = "color_scales")] - lime: ColorScales, + pub(crate) lime: ColorScales, #[serde(with = "color_scales")] - green: ColorScales, + pub(crate) green: ColorScales, #[serde(with = "color_scales")] - emerald: ColorScales, + pub(crate) emerald: ColorScales, #[serde(with = "color_scales")] - teal: ColorScales, + pub(crate) teal: ColorScales, #[serde(with = "color_scales")] - cyan: ColorScales, + pub(crate) cyan: ColorScales, #[serde(with = "color_scales")] - sky: ColorScales, + pub(crate) sky: ColorScales, #[serde(with = "color_scales")] - blue: ColorScales, + pub(crate) blue: ColorScales, #[serde(with = "color_scales")] - indigo: ColorScales, + pub(crate) indigo: ColorScales, #[serde(with = "color_scales")] - violet: ColorScales, + pub(crate) violet: ColorScales, #[serde(with = "color_scales")] - purple: ColorScales, + pub(crate) purple: ColorScales, #[serde(with = "color_scales")] - fuchsia: ColorScales, + pub(crate) fuchsia: ColorScales, #[serde(with = "color_scales")] - pink: ColorScales, + pub(crate) pink: ColorScales, #[serde(with = "color_scales")] - rose: ColorScales, + pub(crate) rose: ColorScales, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)] -struct ShacnColor { +pub(crate) struct ShacnColor { #[serde(default)] - scale: usize, + pub(crate) scale: usize, #[serde(deserialize_with = "from_hsa_channel", alias = "hslChannel")] - hsla: Hsla, + pub(crate) hsla: Hsla, } /// Deserialize Hsla from a string in the format "210 40% 98%" @@ -105,7 +133,9 @@ where } fn parse_number(s: &str) -> f32 { - s.trim_end_matches('%').parse().unwrap_or(0.0) + s.trim_end_matches('%') + .parse() + .expect("failed to parse number") } let (h, s, l) = ( diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index c51cbbb0..c9dd751a 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -42,6 +42,7 @@ pub enum IconName { Minimize, Minus, Moon, + Palette, Plus, Search, SortAscending, @@ -93,6 +94,7 @@ impl IconName { IconName::Minimize => "icons/minimize.svg", IconName::Minus => "icons/minus.svg", IconName::Moon => "icons/moon.svg", + IconName::Palette => "icons/palette.svg", IconName::Plus => "icons/plus.svg", IconName::Search => "icons/search.svg", IconName::SortAscending => "icons/sort-ascending.svg", diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 9b0422b8..1c5afbea 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -11,6 +11,7 @@ pub mod animation; pub mod button; pub mod checkbox; pub mod clipboard; +pub mod color_picker; pub mod context_menu; pub mod divider; pub mod dock; diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 539c4294..8eea59bd 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -35,9 +35,9 @@ impl<'a> ActiveTheme for WindowContext<'a> { /// Make a [gpui::Hsla] color. /// -/// h - 0 - 360.0 -/// s - 0.0 - 100.0 -/// l - 0.0 - 100.0 +/// - h: 0..360.0 +/// - s: 0.0..100.0 +/// - l: 0.0..100.0 pub fn hsl(h: f32, s: f32, l: f32) -> Hsla { hsla(h / 360., s / 100.0, l / 100.0, 1.0) }