From f5cae46e428f6c0e9863000bda06ae3ca48c30ed Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 2 Sep 2024 13:56:19 +0800 Subject: [PATCH] Improve ColorPicker to support input hex color. (#201) https://github.com/user-attachments/assets/be5fc290-0464-4356-9f97-c35720e3e11d --- crates/app/src/story_workspace.rs | 34 +++- crates/story/src/input_story.rs | 23 --- crates/ui/src/color_picker.rs | 304 ++++++++++++++++++------------ crates/ui/src/colors.rs | 64 ++++++- crates/ui/src/dropdown.rs | 1 + crates/ui/src/styled.rs | 22 ++- crates/ui/src/theme.rs | 14 +- 7 files changed, 300 insertions(+), 162 deletions(-) diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index c8785bd9..50908243 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -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, cx: &mut AppContext) { } pub struct StoryWorkspace { - locale_selector: View, dock_area: View, + locale_selector: View, + theme_color_picker: View, } 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.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) diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index 90970614..e58c9488 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -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, otp_input_large: View, opt_input_sized: View, - color_picker: View, } 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.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() diff --git a/crates/ui/src/color_picker.rs b/crates/ui/src/color_picker.rs index aefdfec9..f0ac0950 100644 --- a/crates/ui/src/color_picker.rs +++ b/crates/ui/src/color_picker.rs @@ -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> { + use crate::colors::DEFAULT_COLOR; use itertools::Itertools as _; macro_rules! c { @@ -55,22 +58,47 @@ fn color_palettes() -> Vec> { pub struct ColorPicker { id: ElementId, focus_handle: FocusHandle, - featured_colors: Vec, value: Option, - cleanable: bool, - open: bool, - size: Size, - width: Length, + featured_colors: Vec, hovered_color: Option, + label: Option, + size: Size, + anchor: AnchorCorner, + color_input: View, + + open: bool, + bounds: Bounds, } impl ColorPicker { pub fn new(id: impl Into, cx: &mut ViewContext) -> 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) -> 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) -> 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.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.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) -> Self { + self.label = Some(label.into()); + self } - fn clean(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext) { - 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.open = false; + cx.notify(); } fn toggle_picker(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext) { @@ -125,9 +165,19 @@ impl ColorPicker { cx.notify(); } - fn update_value(&mut self, value: Option, cx: &mut ViewContext) { + fn update_value(&mut self, value: Option, emit: bool, cx: &mut ViewContext) { 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) -> 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) -> Point { + 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) -> Self { + self.size = size.into(); + self + } +} impl EventEmitter 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) -> 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), ) }) } diff --git a/crates/ui/src/colors.rs b/crates/ui/src/colors.rs index d1307b5c..07b9da99 100644 --- a/crates/ui/src/colors.rs +++ b/crates/ui/src/colors.rs @@ -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; } 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 { + 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 = @@ -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()); + } } diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 3a71de53..8c1160d4 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -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), diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 71b0f1f6..5b19f7fc 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -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 { 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 StyleSized 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 StyleSized 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 { diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 8eea59bd..f337c8a1 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -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 } }