Improve ColorPicker to support input hex color. (#201)

https://github.com/user-attachments/assets/be5fc290-0464-4356-9f97-c35720e3e11d
This commit is contained in:
Jason Lee 2024-09-02 13:56:19 +08:00 committed by GitHub
parent a049f606c0
commit f5cae46e42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 300 additions and 162 deletions

View file

@ -11,12 +11,13 @@ use workspace::TitleBar;
use std::sync::Arc;
use ui::{
button::Button,
color_picker::{ColorPicker, ColorPickerEvent},
dock::{DockArea, StackPanel, TabPanel},
drawer::Drawer,
h_flex,
modal::Modal,
popup_menu::PopupMenuExt,
theme::{ActiveTheme, Theme},
theme::{ActiveTheme, Colorize as _, Theme},
ContextModal, IconName, Root, Sizable,
};
@ -38,8 +39,9 @@ pub fn init(_app_state: Arc<AppState>, cx: &mut AppContext) {
}
pub struct StoryWorkspace {
locale_selector: View<LocaleSelector>,
dock_area: View<DockArea>,
locale_selector: View<LocaleSelector>,
theme_color_picker: View<ColorPicker>,
}
impl StoryWorkspace {
@ -228,9 +230,34 @@ impl StoryWorkspace {
let locale_selector = cx.new_view(LocaleSelector::new);
let theme_color_picker = cx.new_view(|cx| {
let mut picker = ColorPicker::new("theme-color-picker", cx)
.xsmall()
.anchor(AnchorCorner::TopRight)
.label("Primary Color");
picker.set_value(cx.theme().primary, cx);
picker
});
cx.subscribe(
&theme_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 {
dock_area,
locale_selector,
theme_color_picker,
}
}
@ -326,7 +353,7 @@ impl Render for StoryWorkspace {
.justify_end()
.px_2()
.gap_2()
.child(self.locale_selector.clone())
.child(self.theme_color_picker.clone())
.child(
Button::new("theme-mode", cx)
.map(|this| {
@ -347,6 +374,7 @@ impl Render for StoryWorkspace {
Theme::change(mode, cx);
}),
)
.child(self.locale_selector.clone())
.child(
Button::new("github", cx)
.icon(IconName::GitHub)

View file

@ -7,11 +7,9 @@ use gpui::{
use ui::{
button::Button,
checkbox::Checkbox,
color_picker::{ColorPicker, ColorPickerEvent},
h_flex,
input::{InputEvent, OtpInput, TextInput},
prelude::FluentBuilder as _,
theme::{Colorize, Theme},
v_flex, FocusableCycle, IconName, Sizable,
};
@ -44,7 +42,6 @@ pub struct InputStory {
otp_input_small: View<OtpInput>,
otp_input_large: View<OtpInput>,
opt_input_sized: View<OtpInput>,
color_picker: View<ColorPicker>,
}
impl InputStory {
@ -106,23 +103,6 @@ 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,
@ -167,7 +147,6 @@ impl InputStory {
.default_value("654321")
.with_size(px(55.))
}),
color_picker,
}
}
@ -218,7 +197,6 @@ 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()
@ -266,7 +244,6 @@ 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

@ -1,23 +1,25 @@
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,
anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, relative, AnchorCorner,
AppContext, Bounds, ElementId, EventEmitter, FocusHandle, FocusableView, Hsla,
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
Render, SharedString, StatefulInteractiveElement as _, Styled, View, ViewContext,
VisualContext,
};
use crate::{
colors::DEFAULT_COLOR,
divider::Divider,
h_flex,
input::ClearButton,
input::{InputEvent, TextInput},
popover::Escape,
theme::{ActiveTheme as _, Colorize},
v_flex, ColorExt as _, Icon, IconName, Size, StyleSized as _, StyledExt as _,
tooltip::Tooltip,
v_flex, ColorExt as _, Sizable, Size, StyleSized,
};
const KEY_CONTEXT: &'static str = "ColorPicker";
pub fn init(cx: &mut AppContext) {
let context = Some("ColorPicker");
cx.bind_keys([KeyBinding::new("escape", Escape, context)])
cx.bind_keys([KeyBinding::new("escape", Escape, Some(KEY_CONTEXT))])
}
#[derive(Clone)]
@ -26,6 +28,7 @@ pub enum ColorPickerEvent {
}
fn color_palettes() -> Vec<Vec<Hsla>> {
use crate::colors::DEFAULT_COLOR;
use itertools::Itertools as _;
macro_rules! c {
@ -55,22 +58,47 @@ fn color_palettes() -> Vec<Vec<Hsla>> {
pub struct ColorPicker {
id: ElementId,
focus_handle: FocusHandle,
featured_colors: Vec<Hsla>,
value: Option<Hsla>,
cleanable: bool,
open: bool,
size: Size,
width: Length,
featured_colors: Vec<Hsla>,
hovered_color: Option<Hsla>,
label: Option<SharedString>,
size: Size,
anchor: AnchorCorner,
color_input: View<TextInput>,
open: bool,
bounds: Bounds<Pixels>,
}
impl ColorPicker {
pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> Self {
let color_input = cx.new_view(|cx| TextInput::new(cx).xsmall());
cx.subscribe(&color_input, |this, _, ev: &InputEvent, cx| match ev {
InputEvent::Change(value) => {
if let Ok(color) = Hsla::parse_hex_string(value) {
this.value = Some(color);
this.hovered_color = Some(color);
}
}
InputEvent::PressEnter => {
let val = this.color_input.read(cx).text();
if let Ok(color) = Hsla::parse_hex_string(&val) {
this.open = false;
this.update_value(Some(color), true, cx);
}
}
_ => {}
})
.detach();
Self {
id: id.into(),
focus_handle: cx.focus_handle(),
featured_colors: vec![
crate::black(),
crate::gray_600(),
crate::gray_400(),
crate::white(),
crate::red_600(),
crate::orange_600(),
@ -81,43 +109,55 @@ impl ColorPicker {
crate::purple_600(),
],
value: None,
cleanable: false,
open: false,
size: Size::default(),
width: Length::Auto,
hovered_color: None,
size: Size::Medium,
label: None,
anchor: AnchorCorner::TopLeft,
color_input,
open: false,
bounds: Bounds::default(),
}
}
/// 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
}
/// Set the featured colors to be displayed in the color picker.
///
/// This is used to display a set of colors that the user can quickly select from,
/// for example provided user's last used colors.
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);
/// Set current color value.
pub fn set_value(&mut self, value: Hsla, cx: &mut ViewContext<Self>) {
self.update_value(Some(value), false, cx)
}
/// Set the size of the color picker, default is `Size::Medium`.
pub fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
self.open = false;
cx.notify();
/// Set the label to be displayed above the color picker.
///
/// Default is `None`.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
fn clean(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
self.update_value(None, cx)
/// Set the anchor corner of the color picker.
///
/// Default is `AnchorCorner::TopLeft`.
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
self.anchor = anchor;
self
}
fn on_escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
self.open = false;
cx.notify();
}
fn toggle_picker(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
@ -125,9 +165,19 @@ impl ColorPicker {
cx.notify();
}
fn update_value(&mut self, value: Option<Hsla>, cx: &mut ViewContext<Self>) {
fn update_value(&mut self, value: Option<Hsla>, emit: bool, cx: &mut ViewContext<Self>) {
self.value = value;
cx.emit(ColorPickerEvent::Change(value));
self.hovered_color = value;
self.color_input.update(cx, |view, cx| {
if let Some(value) = value {
view.set_text(value.to_hex_string(), cx);
} else {
view.set_text("", cx);
}
});
if emit {
cx.emit(ColorPickerEvent::Change(value));
}
cx.notify();
}
@ -145,18 +195,22 @@ impl ColorPicker {
.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)))
.hover(|this| {
this.border_color(color.darken(0.3))
.bg(color.lighten(0.1))
.shadow_sm()
})
.active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
.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.update_value(Some(color), true, cx);
view.open = false;
cx.notify();
}))
@ -165,7 +219,7 @@ impl ColorPicker {
fn render_colors(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex()
.gap_2()
.gap_3()
.child(
h_flex().gap_1().children(
self.featured_colors
@ -173,6 +227,7 @@ impl ColorPicker {
.map(|color| self.render_item(*color, true, cx)),
),
)
.child(Divider::horizontal())
.child(
v_flex()
.gap_1()
@ -185,120 +240,135 @@ impl ColorPicker {
)
})),
)
.when_some(self.hovered_color.clone(), |this, hovered_color| {
.when_some(self.hovered_color, |this, hovered_color| {
this.child(Divider::horizontal()).child(
h_flex()
.gap_1()
.gap_2()
.items_center()
.child(
div()
.bg(hovered_color)
.border_1()
.border_color(hovered_color.darken(0.2))
.size_5()
.rounded(px(cx.theme().radius)),
)
.child(hovered_color.to_hex_string()),
.child(self.color_input.clone()),
)
})
}
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
match self.anchor {
AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
AnchorCorner::TopRight => AnchorCorner::BottomRight,
AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
AnchorCorner::BottomRight => AnchorCorner::TopRight,
}
.corner(bounds)
}
}
impl Sizable for ColorPicker {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl EventEmitter<ColorPickerEvent> for ColorPicker {}
impl FocusableView for ColorPicker {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
fn focus_handle(&self, _: &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())
let display_title: SharedString = if let Some(value) = self.value {
value.to_hex_string()
} else {
"Select a color".to_string()
};
"".to_string()
}
.into();
let value = self.value.unwrap_or_else(|| cx.theme().foreground);
let view = cx.view().clone();
div()
.id(self.id.clone())
.key_context("ColorPicker")
.key_context(KEY_CONTEXT)
.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)
.on_action(cx.listener(Self::on_escape))
.child(
div()
h_flex()
.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()
.gap_2()
.items_center()
.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))
})
.line_height(relative(1.))
.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)))
div()
.id("color-picker-square")
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().input)
.rounded(px(cx.theme().radius))
.bg(cx.theme().background)
.shadow_sm()
.overflow_hidden()
.size_with(self.size)
.when_some(self.value, |this, value| {
this.bg(value).border_color(value.darken(0.3))
})
.when(!show_clean, |this| {
this.child(
Icon::new(IconName::Palette)
.text_color(cx.theme().muted_foreground),
)
}),
.tooltip(move |cx| Tooltip::new(display_title.clone(), cx)),
)
.when_some(self.label.clone(), |this, label| this.child(label))
.on_click(cx.listener(Self::toggle_picker))
.child(
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full(),
),
)
.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)),
),
anchored()
.anchor(self.anchor)
.snap_to_window()
.position(self.resolved_corner(self.bounds))
.child(
div()
.track_focus(&self.focus_handle)
.occlude()
.map(|this| match self.anchor {
AnchorCorner::TopLeft | AnchorCorner::TopRight => {
this.mt_1p5()
}
AnchorCorner::BottomLeft | AnchorCorner::BottomRight => {
this.mb_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.on_escape(&Escape, cx)),
)
.child(self.render_colors(cx)),
),
)
.with_priority(2),
.with_priority(1),
)
})
}

View file

@ -6,8 +6,9 @@ use serde::{de::Error, Deserialize, Deserializer};
use crate::theme::hsl;
use anyhow::Result;
pub trait ColorExt {
pub(crate) trait ColorExt {
fn to_hex_string(&self) -> String;
fn parse_hex_string(hex: &str) -> Result<Hsla>;
}
impl ColorExt for Hsla {
@ -17,20 +18,41 @@ impl ColorExt for Hsla {
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)
((rgb.r * 255.) as u32),
((rgb.g * 255.) as u32),
((rgb.b * 255.) as u32),
((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)
((rgb.r * 255.) as u32),
((rgb.g * 255.) as u32),
((rgb.b * 255.) as u32)
)
}
fn parse_hex_string(hex: &str) -> Result<Hsla> {
let hex = hex.trim_start_matches('#');
let len = hex.len();
if len != 6 && len != 8 {
return Err(anyhow::anyhow!("invalid hex color"));
}
let r = u8::from_str_radix(&hex[0..2], 16)? as f32 / 255.;
let g = u8::from_str_radix(&hex[2..4], 16)? as f32 / 255.;
let b = u8::from_str_radix(&hex[4..6], 16)? as f32 / 255.;
let a = if len == 8 {
u8::from_str_radix(&hex[6..8], 16)? as f32 / 255.
} else {
1.
};
let v = gpui::Rgba { r, g, b, a };
let color: Hsla = v.into();
Ok(color)
}
}
pub(crate) static DEFAULT_COLOR: once_cell::sync::Lazy<ShacnColors> =
@ -211,6 +233,8 @@ color_methods!(rose);
#[cfg(test)]
mod tests {
use gpui::{rgb, rgba};
use super::*;
#[test]
@ -229,4 +253,28 @@ mod tests {
assert_eq!(blue_400(), hsl(213.1, 93.9, 67.8));
assert_eq!(indigo_500(), hsl(238.7, 83.5, 66.7));
}
#[test]
fn test_to_hex_string() {
let color: Hsla = rgb(0xf8fafc).into();
assert_eq!(color.to_hex_string(), "#F8FAFC");
let color: Hsla = rgb(0xfef2f2).into();
assert_eq!(color.to_hex_string(), "#FEF2F2");
let color: Hsla = rgba(0x0413fcaa).into();
assert_eq!(color.to_hex_string(), "#0413FCAA");
}
#[test]
fn test_from_hex_string() {
let color: Hsla = Hsla::parse_hex_string("#F8FAFC").unwrap();
assert_eq!(color, rgb(0xf8fafc).into());
let color: Hsla = Hsla::parse_hex_string("#FEF2F2").unwrap();
assert_eq!(color, rgb(0xfef2f2).into());
let color: Hsla = Hsla::parse_hex_string("#0413FCAA").unwrap();
assert_eq!(color, rgba(0x0413fcaa).into());
}
}

View file

@ -662,6 +662,7 @@ where
deferred(
anchored().snap_to_window().child(
div()
.occlude()
.map(|this| match self.menu_width {
Length::Auto => this.w(bounds.size.width),
Length::Definite(w) => this.w(w),

View file

@ -5,7 +5,7 @@ use crate::{
theme::{ActiveTheme, Colorize},
};
use gpui::{
div, px, rems, Axis, Div, Element, EntityId, Fill, FocusHandle, Pixels, Styled, WindowContext,
div, px, Axis, Div, Element, EntityId, Fill, FocusHandle, Pixels, Styled, WindowContext,
};
/// Returns a `Div` as horizontal flex layout.
@ -238,15 +238,17 @@ pub trait StyleSized<T: Styled> {
fn list_size(self, size: Size) -> Self;
fn list_px(self, size: Size) -> Self;
fn list_py(self, size: Size) -> Self;
/// Apply size with the given `Size`.
fn size_with(self, size: Size) -> Self;
}
impl<T: Styled> StyleSized<T> for T {
fn input_text_size(self, size: Size) -> Self {
match size {
Size::XSmall => self.text_size(rems(0.75)),
Size::Small => self.text_size(rems(0.8)),
Size::Medium => self.text_size(rems(0.875)),
Size::Large => self.text_size(rems(1.)),
Size::XSmall => self.text_xs(),
Size::Small => self.text_sm(),
Size::Medium => self.text_base(),
Size::Large => self.text_lg(),
Size::Size(size) => self.text_size(size),
}
}
@ -315,6 +317,16 @@ impl<T: Styled> StyleSized<T> for T {
_ => self.py_1(),
}
}
fn size_with(self, size: Size) -> Self {
match size {
Size::Large => self.size_11(),
Size::Medium => self.size_8(),
Size::Small => self.size_5(),
Size::XSmall => self.size_4(),
Size::Size(size) => self.size(size),
}
}
}
pub trait AxisExt {

View file

@ -76,9 +76,9 @@ impl Colorize for Hsla {
/// Returns a new color with the given opacity.
///
/// The opacity is a value between 0.0 and 1.0, where 0.0 is fully transparent and 1.0 is fully opaque.
fn opacity(&self, opacity: f32) -> Hsla {
fn opacity(&self, factor: f32) -> Hsla {
Hsla {
a: self.a * opacity,
a: self.a * factor.clamp(0.0, 1.0),
..*self
}
}
@ -111,14 +111,16 @@ impl Colorize for Hsla {
}
}
fn lighten(&self, amount: f32) -> Hsla {
let l = (self.l * (1.0 + amount)).min(1.0);
/// Return a new color with the lightness increased by the given factor.
fn lighten(&self, factor: f32) -> Hsla {
let l = (self.l * 1.0 - factor.clamp(0.0, 1.0)).min(1.0);
Hsla { l, ..*self }
}
fn darken(&self, amount: f32) -> Hsla {
let l = (self.l * (1.0 - amount)).max(0.0);
/// Return a new color with the darkness increased by the given factor.
fn darken(&self, factor: f32) -> Hsla {
let l = (self.l * 1.0 - factor.clamp(0.0, 1.0)).max(0.0);
Hsla { l, ..*self }
}