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:
parent
926de77967
commit
1f0e52da50
10 changed files with 401 additions and 37 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5415,6 +5415,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"gpui",
|
||||
"image",
|
||||
"itertools 0.13.0",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"regex",
|
||||
|
|
|
|||
|
|
@ -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
1
assets/icons/palette.svg
Normal 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 |
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ doctest = false
|
|||
[dependencies]
|
||||
gpui.workspace = true
|
||||
anyhow = "1"
|
||||
itertools = "0.13.0"
|
||||
serde = "1.0.203"
|
||||
serde_json = "1"
|
||||
|
||||
|
|
|
|||
305
crates/ui/src/color_picker.rs
Normal file
305
crates/ui/src/color_picker.rs
Normal 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) = (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue