diff --git a/crates/story/src/progress_story.rs b/crates/story/src/progress_story.rs index 38834fea..f6a5e0f9 100644 --- a/crates/story/src/progress_story.rs +++ b/crates/story/src/progress_story.rs @@ -1,16 +1,18 @@ use gpui::{ - div, px, IntoElement, ParentElement, Render, Styled, View, ViewContext, VisualContext, - WindowContext, + div, hsla, px, Hsla, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + View, ViewContext, VisualContext, WindowContext, }; use ui::{ button::Button, + clipboard::Clipboard, divider::Divider, h_flex, indicator::Indicator, progress::Progress, skeleton::Skeleton, slider::{Slider, SliderEvent}, - v_flex, IconName, Sizable, + theme::Colorize, + v_flex, ColorExt as _, ContextModal, IconName, Sizable, }; pub struct ProgressStory { @@ -20,6 +22,9 @@ pub struct ProgressStory { slider1_value: f32, slider2: View, slider2_value: f32, + slider_hsl: [View; 4], + slider_hsl_value: Hsla, + _subscritions: Vec, } impl super::Story for ProgressStory { @@ -45,30 +50,87 @@ impl ProgressStory { .default_value(15.) .step(15.) }); - cx.subscribe(&slider1, |this, _, event: &SliderEvent, cx| match event { - SliderEvent::Change(value) => { - this.slider1_value = *value; - cx.notify(); - } - }) - .detach(); let slider2 = cx.new_view(|_| Slider::horizontal().min(0.).max(5.).step(1.0)); - cx.subscribe(&slider2, |this, _, event: &SliderEvent, cx| match event { - SliderEvent::Change(value) => { - this.slider2_value = *value; - cx.notify(); - } - }) - .detach(); + let slider_hsl = [ + cx.new_view(|_| { + Slider::vertical() + .reverse() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.) + }), + cx.new_view(|_| { + Slider::vertical() + .reverse() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.5) + }), + cx.new_view(|_| { + Slider::vertical() + .reverse() + .min(0.) + .max(1.) + .step(0.01) + .default_value(0.5) + }), + cx.new_view(|_| { + Slider::vertical() + .reverse() + .min(0.) + .max(1.) + .step(0.01) + .default_value(1.) + }), + ]; + + let mut _subscritions = vec![ + cx.subscribe(&slider1, |this, _, event: &SliderEvent, cx| match event { + SliderEvent::Change(value) => { + this.slider1_value = *value; + cx.notify(); + } + }), + cx.subscribe(&slider2, |this, _, event: &SliderEvent, cx| match event { + SliderEvent::Change(value) => { + this.slider2_value = *value; + cx.notify(); + } + }), + ]; + + _subscritions.extend( + slider_hsl + .iter() + .map(|slider| { + cx.subscribe(slider, |this, _, event: &SliderEvent, cx| match event { + SliderEvent::Change(_) => { + this.slider_hsl_value = hsla( + this.slider_hsl[0].read(cx).value(), + this.slider_hsl[1].read(cx).value(), + this.slider_hsl[2].read(cx).value(), + this.slider_hsl[3].read(cx).value(), + ); + cx.notify(); + } + }) + }) + .collect::>(), + ); Self { focus_handle: cx.focus_handle(), value: 50., - slider1_value: 15., - slider2_value: 1., + slider1_value: 0., + slider2_value: 0., slider1, slider2, + slider_hsl, + slider_hsl_value: gpui::red(), + _subscritions, } } @@ -85,6 +147,8 @@ impl gpui::FocusableView for ProgressStory { impl Render for ProgressStory { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let rgb = SharedString::from(self.slider_hsl_value.to_hex_string()); + v_flex() .items_center() .gap_y_3() @@ -160,6 +224,65 @@ impl Render for ProgressStory { .child(self.slider2.clone()) .child(format!("Slider 2: {}", self.slider2_value)), ) + .child( + h_flex() + .gap_3() + .justify_start() + .child( + v_flex() + .w_32() + .h_40() + .gap_3() + .items_center() + .child(self.slider_hsl[0].clone()) + .child(format!("H: {:.0}", self.slider_hsl_value.h * 360.)), + ) + .child( + v_flex() + .w_32() + .h_40() + .gap_3() + .items_center() + .child(self.slider_hsl[1].clone()) + .child(format!("S: {:.0}", self.slider_hsl_value.s * 100.)), + ) + .child( + v_flex() + .w_32() + .h_40() + .gap_3() + .items_center() + .child(self.slider_hsl[2].clone()) + .child(format!("L: {:.0}", self.slider_hsl_value.l * 100.)), + ) + .child( + v_flex() + .w_32() + .h_40() + .gap_3() + .items_center() + .child(self.slider_hsl[3].clone()) + .child(format!("A: {:.0}", self.slider_hsl_value.a * 100.)), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + h_flex() + .w_32() + .p_1() + .rounded_lg() + .justify_center() + .bg(self.slider_hsl_value) + .child(rgb.clone()) + .text_color(self.slider_hsl_value.invert()), + ) + .child(Clipboard::new("copy-hsl").value(rgb).on_copied(|_, cx| { + cx.push_notification("Color copied to clipboard.") + })), + ), + ) .child( h_flex() .mt_5() diff --git a/crates/ui/src/colors.rs b/crates/ui/src/colors.rs index 321b92f7..b6048071 100644 --- a/crates/ui/src/colors.rs +++ b/crates/ui/src/colors.rs @@ -6,8 +6,11 @@ use serde::{de::Error, Deserialize, Deserializer}; use crate::theme::hsl; use anyhow::Result; -pub(crate) trait ColorExt { +/// Extension methods for Hsla. +pub trait ColorExt { + /// Convert the color to a hex string. For example, "#F8FAFC". fn to_hex_string(&self) -> String; + /// Parse a hex string to a color. fn parse_hex_string(hex: &str) -> Result; } diff --git a/crates/ui/src/slider.rs b/crates/ui/src/slider.rs index 731f480c..f4256e16 100644 --- a/crates/ui/src/slider.rs +++ b/crates/ui/src/slider.rs @@ -1,6 +1,6 @@ -use crate::{theme::ActiveTheme, tooltip::Tooltip}; +use crate::{h_flex, theme::ActiveTheme, tooltip::Tooltip, AxisExt}; use gpui::{ - canvas, div, prelude::FluentBuilder as _, px, relative, Axis, Bounds, DragMoveEvent, EntityId, + canvas, div, prelude::FluentBuilder as _, px, Axis, Bounds, DragMoveEvent, EntityId, EventEmitter, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement as _, Styled, ViewContext, VisualContext as _, @@ -20,6 +20,8 @@ pub struct Slider { max: f32, step: f32, value: f32, + reverse: bool, + percentage: f32, bounds: Bounds, } @@ -31,23 +33,39 @@ impl Slider { max: 100.0, step: 1.0, value: 0.0, + percentage: 0.0, + reverse: false, bounds: Bounds::default(), } } + /// Create a horizontal slider. pub fn horizontal() -> Self { Self::new(Axis::Horizontal) } + /// Create a vertical slider. + pub fn vertical() -> Self { + Self::new(Axis::Vertical) + } + + /// Set the reverse direction of the slider, default: false + pub fn reverse(mut self) -> Self { + self.reverse = true; + self + } + /// Set the minimum value of the slider, default: 0.0 pub fn min(mut self, min: f32) -> Self { self.min = min; + self.update_thumb_pos(); self } /// Set the maximum value of the slider, default: 100.0 pub fn max(mut self, max: f32) -> Self { self.max = max; + self.update_thumb_pos(); self } @@ -60,27 +78,24 @@ impl Slider { /// Set the default value of the slider, default: 0.0 pub fn default_value(mut self, value: f32) -> Self { self.value = value; + self.update_thumb_pos(); self } /// Set the value of the slider. pub fn set_value(&mut self, value: f32, cx: &mut gpui::ViewContext) { self.value = value; + self.update_thumb_pos(); cx.notify(); } - /// Return percentage value of the slider, range of 0.0..1.0 - fn relative_value(&self) -> f32 { - let step = self.step; - let value = self.value; - let min = self.min; - let max = self.max; + fn update_thumb_pos(&mut self) { + self.percentage = self.value.clamp(self.min, self.max) / self.max; + } - let relative_value = (value - min) / (max - min); - let relative_step = step / (max - min); - - let relative_value = (relative_value / relative_step).round() * relative_step; - relative_value.clamp(0.0, 1.0) + /// Get the value of the slider. + pub fn value(&self) -> f32 { + self.value } /// Update value by mouse position @@ -95,25 +110,45 @@ impl Slider { let max = self.max; let step = self.step; - let value = match axis { + let percentage = match axis { Axis::Horizontal => { - let relative = (position.x - bounds.left()) / bounds.size.width; - min + (max - min) * relative + if self.reverse { + 1. - (position.x - bounds.left()).clamp(px(0.), bounds.size.width) + / bounds.size.width + } else { + (position.x - bounds.left()).clamp(px(0.), bounds.size.width) + / bounds.size.width + } } Axis::Vertical => { - let relative = (position.y - bounds.top()) / bounds.size.height; - max - (max - min) * relative + if self.reverse { + 1. - (position.y - bounds.top()).clamp(px(0.), bounds.size.height) + / bounds.size.height + } else { + (position.y - bounds.top()).clamp(px(0.), bounds.size.height) + / bounds.size.height + } } }; + let value = match axis { + Axis::Horizontal => min + (max - min) * percentage, + Axis::Vertical => max - (max - min) * percentage, + }; + let value = (value / step).round() * step; + self.percentage = percentage; self.value = value.clamp(self.min, self.max); cx.emit(SliderEvent::Change(self.value)); cx.notify(); } - fn render_thumb(&self, cx: &mut ViewContext) -> impl gpui::IntoElement { + fn render_thumb( + &self, + thumb_bar_size: Pixels, + cx: &mut ViewContext, + ) -> impl gpui::IntoElement { let value = self.value; let entity_id = cx.entity_id(); @@ -136,9 +171,22 @@ impl Slider { }, )) .absolute() - .top(px(-5.)) - .left(relative(self.relative_value())) - .ml(-px(8.)) + .map(|this| match self.reverse { + true => this + .when(self.axis.is_horizontal(), |this| { + this.bottom(px(-5.)).right(thumb_bar_size).mr(-px(8.)) + }) + .when(self.axis.is_vertical(), |this| { + this.bottom(thumb_bar_size).right(px(-5.)).mb(-px(8.)) + }), + false => this + .when(self.axis.is_horizontal(), |this| { + this.top(px(-5.)).left(thumb_bar_size).ml(-px(8.)) + }) + .when(self.axis.is_vertical(), |this| { + this.top(thumb_bar_size).left(px(-5.)).mt(-px(8.)) + }), + }) .size_4() .rounded_full() .border_1() @@ -157,31 +205,45 @@ impl EventEmitter for Slider {} impl Render for Slider { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() + let thumb_bar_size = match self.axis { + Axis::Horizontal => self.percentage * self.bounds.size.width, + Axis::Vertical => self.percentage * self.bounds.size.height, + }; + + h_flex() .id("slider") .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) - .h_5() + .when(self.axis.is_horizontal(), |this| { + this.items_center().h_6().w_full() + }) + .when(self.axis.is_vertical(), |this| { + this.justify_center().w_6().h_full() + }) + .flex_shrink_0() .child( div() .id("slider-bar") .relative() - .w_full() - .my_1p5() - .h_1p5() + .when(self.axis.is_horizontal(), |this| this.w_full().h_1p5()) + .when(self.axis.is_vertical(), |this| this.h_full().w_1p5()) .bg(cx.theme().slider_bar.opacity(0.2)) .active(|this| this.bg(cx.theme().slider_bar.opacity(0.4))) .rounded(px(3.)) .child( div() .absolute() - .top_0() - .left_0() - .h_full() - .w(relative(self.relative_value())) + .when(!self.reverse, |this| this.top_0().left_0()) + .when(self.reverse, |this| this.bottom_0().right_0()) + .when(self.axis.is_horizontal(), |this| { + this.h_full().w(thumb_bar_size) + }) + .when(self.axis.is_vertical(), |this| { + this.w_full().h(thumb_bar_size) + }) .bg(cx.theme().slider_bar) - .rounded_l(px(3.)), + .rounded_full(), ) - .child(self.render_thumb(cx)) + .child(self.render_thumb(thumb_bar_size, cx)) .child({ let view = cx.view().clone(); canvas( diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 14813dee..99d9a1ec 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -70,19 +70,31 @@ pub fn box_shadow( } } pub trait Colorize { + /// 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; + /// Returns a new color with each channel divided by the given divisor. + /// + /// The divisor in range of 0.0 .. 1.0 fn divide(&self, divisor: f32) -> Hsla; + /// Return inverted color fn invert(&self) -> Hsla; + /// Return inverted lightness fn invert_l(&self) -> Hsla; + /// Return a new color with the lightness increased by the given factor. + /// + /// factor range: 0.0 .. 1.0 fn lighten(&self, amount: f32) -> Hsla; + /// Return a new color with the darkness increased by the given factor. + /// + /// factor range: 0.0 .. 1.0 fn darken(&self, amount: f32) -> Hsla; + /// Return a new color with the same lightness and alpha but different hue and saturation. fn apply(&self, base_color: Hsla) -> Hsla; } 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, factor: f32) -> Hsla { Hsla { a: self.a * factor.clamp(0.0, 1.0), @@ -90,9 +102,6 @@ impl Colorize for Hsla { } } - /// Returns a new color with each channel divided by the given divisor. - /// - /// The divisor in range of 0.0 .. 1.0 fn divide(&self, divisor: f32) -> Hsla { Hsla { a: divisor, @@ -100,17 +109,15 @@ impl Colorize for Hsla { } } - /// Return inverted color fn invert(&self) -> Hsla { Hsla { - h: (self.h + 1.8) % 3.6, + h: 1.0 - self.h, s: 1.0 - self.s, l: 1.0 - self.l, a: self.a, } } - /// Return inverted lightness fn invert_l(&self) -> Hsla { Hsla { l: 1.0 - self.l, @@ -118,25 +125,18 @@ impl Colorize for Hsla { } } - /// Return a new color with the lightness increased by the given factor. - /// - /// factor range: 0.0 .. 1.0 fn lighten(&self, factor: f32) -> Hsla { let l = self.l * (1.0 + factor.clamp(0.0, 1.0)); Hsla { l, ..*self } } - /// Return a new color with the darkness increased by the given factor. - /// - /// factor range: 0.0 .. 1.0 fn darken(&self, factor: f32) -> Hsla { let l = self.l * (1.0 - factor.clamp(0.0, 1.0)); Hsla { l, ..*self } } - /// Return a new color with the same lightness and alpha but different hue and saturation. fn apply(&self, new_color: Hsla) -> Hsla { Hsla { h: new_color.h,