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)
}