diff --git a/CHANGELOG.md b/CHANGELOG.md index 626f581..af01891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 component provided. This allows the spacer to use values from the theme at runtime. - `Space::primary()` returns a space that contains the primary color. +- `Hsl` is a new color type that is composed of hue, saturation, and lightness. +- `HslPicker` is a color picker for `Hsl` colors. +- `LightnessPicker` is a picker of lightness values. [99]: https://github.com/khonsulabs/cushy/issues/99 [120]: https://github.com/khonsulabs/cushy/issues/120 diff --git a/examples/color-pickers.rs b/examples/color-pickers.rs new file mode 100644 index 0000000..a676753 --- /dev/null +++ b/examples/color-pickers.rs @@ -0,0 +1,27 @@ +use cushy::styles::Hsl; +use cushy::value::{Dynamic, Source}; +use cushy::widget::MakeWidget; +use cushy::widgets::color::HslPicker; +use cushy::widgets::Space; +use cushy::Run; +use figures::units::Lp; +use figures::Size; +use kludgine::Color; + +fn main() -> cushy::Result { + let hsl = Dynamic::new(Hsl::from(Color::RED)); + let color = hsl.map_each_cloned(Color::from); + "Picker" + .and(HslPicker::new(hsl).expand()) + .into_rows() + .expand() + .and( + "Picked Color" + .and(Space::colored(color).size(Size::squared(Lp::inches(1)))) + .into_rows(), + ) + .into_columns() + .pad() + .expand() + .run() +} diff --git a/guide/src/about/composition.md b/guide/src/about/composition.md index 4e12b60..4c9e415 100644 --- a/guide/src/about/composition.md +++ b/guide/src/about/composition.md @@ -253,7 +253,7 @@ approach is how the built-in [`Checkbox`][checkbox], [`Radio`][radio] and [align]: ../widgets/layout/align.md [expand]: ../widgets/layout/expand.md [book-example-wrapper]: <{{ src }}/guide/guide-examples/examples/composition-wrapperwidget.rs> -[book-example-widget]: <{{ src }}/guide/guide-examples/examples/composition-wrapperwidget.rs> +[book-example-widget]: <{{ src }}/guide/guide-examples/examples/composition-widget.rs> [measuredtext]: [windowlocal]: <{{ docs }}/window/struct.WindowLocal.html> [constraintlimit-min]: <{{ docs }}/enum.ConstraintLimit.html#method.min> diff --git a/src/styles.rs b/src/styles.rs index 3e0a4aa..bf02a4d 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1851,18 +1851,18 @@ impl Lightness for u8 { /// Extra functionality added to the [`Color`] type from Kludgine. pub trait ColorExt: Copy { - /// Converts this color into its hue/saturation and lightness components. - fn into_source_and_lightness(self) -> (ColorSource, ZeroToOne); + /// Converts this color into its hue, saturation, and lightness components. + fn into_hsl(self) -> Hsl; /// Returns the hue and saturation of this color. fn source(self) -> ColorSource { - self.into_source_and_lightness().0 + self.into_hsl().source } /// Returns the perceived lightness of this color. #[must_use] fn lightness(self) -> ZeroToOne { - self.into_source_and_lightness().1 + self.into_hsl().lightness } /// Returns the contrast between this color and the components provided. @@ -1893,16 +1893,25 @@ pub trait ColorExt: Copy { } impl ColorExt for Color { - fn into_source_and_lightness(self) -> (ColorSource, ZeroToOne) { - let hsl: palette::Okhsl = + fn into_hsl(self) -> Hsl { + let mut hsl: palette::Okhsl = Srgb::new(self.red_f32(), self.green_f32(), self.blue_f32()).into_color(); - ( - ColorSource { + + if hsl.saturation.is_nan() && self.red() == 255 && self.green() == 255 && self.blue() == 255 + { + // This works around a calculation causing NaN in the saturation + // field when pure white is converted: + // + hsl.saturation = 0.0; + } + + Hsl { + source: ColorSource { hue: hsl.hue, saturation: ZeroToOne::new(hsl.saturation), }, - ZeroToOne::new(hsl.lightness * self.alpha_f32()), - ) + lightness: ZeroToOne::new(hsl.lightness * self.alpha_f32()), + } } fn contrast_between( @@ -1911,12 +1920,12 @@ impl ColorExt for Color { check_lightness: ZeroToOne, check_alpha: ZeroToOne, ) -> ZeroToOne { - let (other_source, other_lightness) = self.into_source_and_lightness(); - let lightness_delta = other_lightness.difference_between(check_lightness); + let other = self.into_hsl(); + let lightness_delta = other.lightness.difference_between(check_lightness); - let average_lightness = ZeroToOne::new((*check_lightness + *other_lightness) / 2.); + let average_lightness = ZeroToOne::new((*check_lightness + *other.lightness) / 2.); - let source_change = check_source.contrast_between(other_source); + let source_change = check_source.contrast_between(other.source); let other_alpha = ZeroToOne::new(self.alpha_f32()); let alpha_delta = check_alpha.difference_between(other_alpha); @@ -1933,16 +1942,16 @@ impl ColorExt for Color { where Self: Copy, { - let (check_source, check_lightness) = self.into_source_and_lightness(); + let check = self.into_hsl(); let check_alpha = ZeroToOne::new(self.alpha_f32()); let mut others = others.iter().copied(); let mut most_contrasting = others.next().expect("at least one comparison"); let mut most_contrast_amount = - most_contrasting.contrast_between(check_source, check_lightness, check_alpha); + most_contrasting.contrast_between(check.source, check.lightness, check_alpha); for other in others { let contrast_amount = - other.contrast_between(check_source, check_lightness, check_alpha); + other.contrast_between(check.source, check.lightness, check_alpha); if contrast_amount > most_contrast_amount { most_contrasting = other; most_contrast_amount = contrast_amount; @@ -1953,6 +1962,27 @@ impl ColorExt for Color { } } +/// A color composed of hue, saturation, and lightness. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Hsl { + /// The hue and saturation of this color. + pub source: ColorSource, + /// The lightness of this color. + pub lightness: ZeroToOne, +} + +impl From for Hsl { + fn from(value: Color) -> Self { + value.into_hsl() + } +} + +impl From for Color { + fn from(value: Hsl) -> Self { + value.source.color(value.lightness) + } +} + /// A 2d ordering configuration. #[derive(Copy, Debug, Clone, Eq, PartialEq)] pub struct VisualOrder { diff --git a/src/widgets/color.rs b/src/widgets/color.rs index f026ed9..2b20dfe 100644 --- a/src/widgets/color.rs +++ b/src/widgets/color.rs @@ -2,20 +2,188 @@ use std::ops::Range; use figures::units::{Lp, Px}; -use figures::{FloatConversion, Point, Rect, Round, ScreenScale, Zero}; +use figures::{FloatConversion, Point, Rect, Round, ScreenScale, Size, Zero}; use intentional::Cast; use kludgine::app::winit::event::MouseButton; -use kludgine::shapes::{self, FillOptions, PathBuilder, Shape, StrokeOptions}; +use kludgine::shapes::{self, CornerRadii, FillOptions, PathBuilder, Shape, StrokeOptions}; use kludgine::{Color, DrawableExt, Origin}; use crate::animation::ZeroToOne; use crate::context::{EventContext, GraphicsContext}; use crate::styles::components::{HighlightColor, OutlineColor, TextColor}; -use crate::styles::{ColorExt, ColorSource}; -use crate::value::{Destination, Dynamic, IntoValue, Source, Value}; -use crate::widget::{EventHandling, Widget, HANDLED}; +use crate::styles::{ColorExt, ColorSource, Hsl}; +use crate::value::{Destination, Dynamic, ForEachCloned, IntoDynamic, IntoValue, Source, Value}; +use crate::widget::{ + EventHandling, MakeWidget, MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag, HANDLED, +}; use crate::window::DeviceId; +/// A color picker that selects an [`Hsl`] color. +#[derive(Debug)] +pub struct HslPicker { + source: Dynamic, + lightness: Dynamic, +} + +impl HslPicker { + /// Returns a new color picker that updates `hsl` when a new value is + /// chosen. + #[must_use] + pub fn new(hsl: Dynamic) -> Self { + let source = hsl.map_each(|hsl| hsl.source); + let lightness = hsl.map_each(|hsl| hsl.lightness); + + (&source, &lightness) + .for_each_cloned(move |(source, lightness)| hsl.set(Hsl { source, lightness })) + .persist(); + + Self { source, lightness } + } +} + +impl MakeWidgetWithTag for HslPicker { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + ColorSourcePicker::new(self.source) + .lightness(self.lightness.clone()) + .make_with_tag(tag) + .expand() + .and(LightnessPicker::new(self.lightness).height(Lp::points(24))) + .into_rows() + .gutter(Px::ZERO) + .make_widget() + } +} + +/// A widget that selects between completely dark and completely light by +/// utilizing a back-to-white gradient. +#[derive(Debug)] +pub struct LightnessPicker { + value: Dynamic, + visible_rect: Rect, +} + +impl LightnessPicker { + /// Returns a new picker that updates `value` when a new lightness is + /// selected. + pub fn new(value: impl IntoDynamic) -> Self { + Self { + value: value.into_dynamic(), + visible_rect: Rect::default(), + } + } + + fn update_from_mouse(&mut self, location: Point) { + let relative = (location - self.visible_rect.origin) + .clamp(Point::ZERO, Point::from(self.visible_rect.size)); + + let lightness = relative.x.into_float() / self.visible_rect.size.width.into_float(); + + self.value.set(ZeroToOne::new(lightness)); + } +} + +impl Widget for LightnessPicker { + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { + const STEPS: u8 = 10; + let loupe_size = Lp::mm(3).into_px(context.gfx.scale()); + let size = context.gfx.region().size; + + let outline_color = if context.focused(true) { + context.get(&HighlightColor) + } else { + context.get(&OutlineColor) + }; + + let options = StrokeOptions::lp_wide(Lp::points(1)) + .colored(outline_color) + .into_px(context.gfx.scale()); + self.visible_rect = Rect::new( + Point::squared(options.line_width) + Point::new(loupe_size / 2, Px::ZERO), + size - Size::squared(options.line_width * 2) - Size::new(loupe_size, Px::ZERO), + ); + + let (top_left, bottom_right) = self.visible_rect.extents(); + + // Unfortunately, drawing a single back-to-white gradient doesn't work + // visually due to srgb mapping. + let mut x = top_left.x; + let top = top_left.y; + let bottom = bottom_right.y; + let mut lightness = ZeroToOne::ZERO; + let step_width = self.visible_rect.size.width / i32::from(STEPS); + let step_lightness = 1.0 / f32::from(STEPS); + let mut gray = Color::BLACK; + for step in 0..STEPS { + let (end_x, end_gray) = if step == STEPS - 1 { + (bottom_right.x, Color::WHITE) + } else { + lightness = ZeroToOne::new(*lightness + step_lightness); + (x + step_width, ColorSource::new(0., 0.).color(lightness)) + }; + context.gfx.draw_shape( + &PathBuilder::new((Point::new(x, top), gray)) + .line_to((Point::new(end_x, top), end_gray)) + .line_to((Point::new(end_x, bottom), end_gray)) + .line_to((Point::new(x, bottom), gray)) + .close() + .filled(), + ); + x = end_x; + gray = end_gray; + } + + context.gfx.draw_shape(&Shape::stroked_rect( + self.visible_rect.inset(-options.line_width / 2), + options, + )); + + let value = self.value.get_tracking_redraw(context); + let value_x = self.visible_rect.origin.x + self.visible_rect.size.width * *value; + let loupe_rect = Rect::new( + Point::new(value_x - loupe_size / 2, options.line_width / 2), + Size::new(loupe_size, size.height - options.line_width), + ); + let selected_gray = ColorSource::new(0., 0.).color(value); + context.gfx.draw_shape(&Shape::filled_round_rect( + loupe_rect, + CornerRadii::from(loupe_size), + selected_gray, + )); + let loupe_color = + selected_gray.most_contrasting(&[context.get(&OutlineColor), context.get(&TextColor)]); + context.gfx.draw_shape(&Shape::stroked_round_rect( + loupe_rect, + CornerRadii::from(loupe_size), + options.colored(loupe_color), + )); + } + + fn mouse_down( + &mut self, + location: Point, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_>, + ) -> EventHandling { + self.update_from_mouse(location); + HANDLED + } + + fn mouse_drag( + &mut self, + location: Point, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_>, + ) { + self.update_from_mouse(location); + } + + fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_>) -> bool { + true + } +} + /// A widget that selects a [`ColorSource`]. #[derive(Debug)] pub struct ColorSourcePicker { @@ -31,9 +199,9 @@ impl ColorSourcePicker { /// Returns a new color picker that updates `value` when a new value is /// selected. #[must_use] - pub fn new(value: Dynamic) -> Self { + pub fn new(value: impl IntoDynamic) -> Self { Self { - value, + value: value.into_dynamic(), lightness: Value::Constant(ZeroToOne::new(0.5)), visible_rect: Rect::default(), hue_is_360: false, @@ -74,6 +242,20 @@ impl Widget for ColorSourcePicker { let loupe_size = Lp::mm(3).into_px(context.gfx.scale()); let size = context.gfx.region().size; + let outline_color = if context.focused(true) { + context.get(&HighlightColor) + } else { + context.get(&OutlineColor) + }; + + let options = StrokeOptions::lp_wide(Lp::points(1)) + .colored(outline_color) + .into_px(context.gfx.scale()); + self.visible_rect = Rect::new( + Point::squared(options.line_width) + loupe_size / 2, + size - Point::squared(options.line_width * 2) - loupe_size, + ); + let value = self.value.get_tracking_redraw(context); let value_pos = self.visible_rect.origin + Point::new( @@ -88,20 +270,6 @@ impl Widget for ColorSourcePicker { let lightness = self.lightness.get_tracking_redraw(context); let value_color = value.color(lightness); - let outline_color = if context.focused(true) { - context.get(&HighlightColor) - } else { - context.get(&OutlineColor) - }; - - let options = StrokeOptions::lp_wide(Lp::points(1)) - .colored(outline_color) - .into_px(context.gfx.scale()); - self.visible_rect = Rect::new( - Point::squared(options.line_width / 2) + loupe_size / 2, - size - Point::squared(options.line_width) - loupe_size, - ); - let max_steps = (self.visible_rect.size.width / 2).floor().get(); let steps = (self.visible_rect.size.width / 2) .floor() @@ -133,9 +301,10 @@ impl Widget for ColorSourcePicker { hue = end_hue; } - context - .gfx - .draw_shape(&Shape::stroked_rect(self.visible_rect, options)); + context.gfx.draw_shape(&Shape::stroked_rect( + self.visible_rect.inset(-options.line_width / 2), + options, + )); // Draw the loupe context.gfx.draw_shape( @@ -183,48 +352,47 @@ fn draw_gradient_segment( lightness: ZeroToOne, context: &mut GraphicsContext<'_, '_, '_, '_>, ) { - let mid_left = ( - Point::new(start.x, start.y + height / 2), - ColorSource::new(hue.start, ZeroToOne::new(0.5)).color(lightness), - ); - let mid_right = ( - Point::new(end, start.y + height / 2), - ColorSource::new(hue.end, ZeroToOne::new(0.5)).color(lightness), - ); + let vertical_slices = (height.get() / 16).clamp(3, 10); + let slice_neight = height / vertical_slices; + let slice_saturation = 1.0 / vertical_slices.cast::(); - context.gfx.draw_shape( - &PathBuilder::new(( - start, - ColorSource::new(hue.start, ZeroToOne::ONE).color(lightness), - )) - .line_to(( - Point::new(end, start.y), - ColorSource::new(hue.end, ZeroToOne::ONE).color(lightness), - )) - .line_to(mid_right) - .line_to(mid_left) - .close() - .fill_opt( - Color::WHITE, - &FillOptions::DEFAULT.with_sweep_orientation(shapes::Orientation::Horizontal), - ), - ); + let mut y = start.y; + let mut saturation = ZeroToOne::ONE; + for slice in 0..vertical_slices { + let (bottom, bottom_saturation) = if slice + 1 == vertical_slices { + (start.y + height, ZeroToOne::ZERO) + } else { + ( + y + slice_neight, + ZeroToOne::new(*saturation - slice_saturation), + ) + }; - context.gfx.draw_shape( - &PathBuilder::new(mid_left) - .line_to(mid_right) - .line_to(( - Point::new(end, start.y + height), - ColorSource::new(hue.end, ZeroToOne::ZERO).color(lightness), + context.gfx.draw_shape( + &PathBuilder::new(( + Point::new(start.x, y), + ColorSource::new(hue.start, saturation).color(lightness), )) .line_to(( - Point::new(start.x, start.y + height), - ColorSource::new(hue.start, ZeroToOne::ZERO).color(lightness), + Point::new(end, y), + ColorSource::new(hue.end, saturation).color(lightness), + )) + .line_to(( + Point::new(end, bottom), + ColorSource::new(hue.end, bottom_saturation).color(lightness), + )) + .line_to(( + Point::new(start.x, bottom), + ColorSource::new(hue.start, bottom_saturation).color(lightness), )) .close() .fill_opt( Color::WHITE, &FillOptions::DEFAULT.with_sweep_orientation(shapes::Orientation::Horizontal), ), - ); + ); + + y += slice_neight; + saturation = bottom_saturation; + } }