Add ColorPicker (#199)

<img width="262" alt="image"
src="https://github.com/user-attachments/assets/ad96859f-e6cb-47a6-bedb-fef02122b711">
This commit is contained in:
Jason Lee 2024-09-02 00:21:01 +08:00 committed by GitHub
parent 926de77967
commit 1f0e52da50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 401 additions and 37 deletions

1
Cargo.lock generated
View file

@ -5415,6 +5415,7 @@ dependencies = [
"chrono",
"gpui",
"image",
"itertools 0.13.0",
"once_cell",
"paste",
"regex",

View file

@ -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

1
assets/icons/palette.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-palette"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>

After

Width:  |  Height:  |  Size: 666 B

View file

@ -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<OtpInput>,
otp_input_large: View<OtpInput>,
opt_input_sized: View<OtpInput>,
color_picker: View<ColorPicker>,
}
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>();
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()

View file

@ -11,6 +11,7 @@ doctest = false
[dependencies]
gpui.workspace = true
anyhow = "1"
itertools = "0.13.0"
serde = "1.0.203"
serde_json = "1"

View file

@ -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<Hsla>),
}
fn color_palettes() -> Vec<Vec<Hsla>> {
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<_>>()
};
}
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<Hsla>,
value: Option<Hsla>,
cleanable: bool,
open: bool,
size: Size,
width: Length,
hovered_color: Option<Hsla>,
}
impl ColorPicker {
pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> 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<Length>) -> Self {
self.width = width.into();
self
}
pub fn featured_colors(mut self, colors: Vec<Hsla>) -> 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>) {
self.open = false;
cx.notify();
}
fn clean(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
self.update_value(None, cx)
}
fn toggle_picker(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
self.open = !self.open;
cx.notify();
}
fn update_value(&mut self, value: Option<Hsla>, cx: &mut ViewContext<Self>) {
self.value = value;
cx.emit(ColorPickerEvent::Change(value));
cx.notify();
}
fn render_item(
&self,
color: Hsla,
clickable: bool,
cx: &mut ViewContext<Self>,
) -> 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<Self>) -> 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<ColorPickerEvent> 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<Self>) -> 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),
)
})
}
}

View file

@ -6,10 +6,38 @@ use serde::{de::Error, Deserialize, Deserializer};
use crate::theme::hsl;
use anyhow::Result;
static DEFAULT_COLOR: once_cell::sync::Lazy<ShacnColors> = 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<ShacnColors> =
once_cell::sync::Lazy::new(|| {
serde_json::from_str(include_str!("../default-colors.json"))
.expect("failed to parse default-json")
});
type ColorScales = HashMap<usize, ShacnColor>;
@ -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) = (

View file

@ -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",

View file

@ -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;

View file

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