From 8a274df730e371d5d8f14612cdef247ed112820c Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 10 Jan 2024 13:27:12 -0800 Subject: [PATCH] Added more color pickers This set of changes is making me think of adding Rgb/Rgba types and having our own color enum. --- CHANGELOG.md | 14 +- examples/color-pickers.rs | 21 +- src/styles.rs | 62 ++++-- src/value.rs | 6 +- src/widgets/color.rs | 447 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 498 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af01891..154116f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 implements `Zero` which defines an associated constant of the same name and purpose. - `Children` has been renamed to `WidgetList`. +- `ColorExt::into_source_and_lightness` has been renamed to + `ColorExt::into_hsl`, and its return type is now `Hsl` instead of the + individual components. ### Fixed @@ -175,8 +178,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- `Hsla` is a new color type that combines `Hsl` with an alpha component. +- Additional color pickers are now available: + + - `HslPicker` picks `Hsl` + - `HslaPicker` picks `Hsla` + - `RgbPicker` picks `Color` with 255/1.0 alpha channel + - `RgbaPicker` picks `Color` +- `ComponentPicker` is a picker of various `ColorComponent` implementors. It has + constructors for each [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 index a676753..68bb195 100644 --- a/examples/color-pickers.rs +++ b/examples/color-pickers.rs @@ -1,7 +1,7 @@ -use cushy::styles::Hsl; +use cushy::styles::Hsla; use cushy::value::{Dynamic, Source}; use cushy::widget::MakeWidget; -use cushy::widgets::color::HslPicker; +use cushy::widgets::color::{HslaPicker, RgbaPicker}; use cushy::widgets::Space; use cushy::Run; use figures::units::Lp; @@ -9,16 +9,23 @@ 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()) + let color = Dynamic::new(Color::RED); + let color_as_string = color.map_each(|color| format!("{color:?}")); + + let hsl = color.linked(|color| Hsla::from(*color), |hsl| Color::from(*hsl)); + + "HSLa Picker" + .and(HslaPicker::new(hsl).expand()) + .and("RGBa Picker") + .and(RgbaPicker::new(color.clone())) .into_rows() .expand() .and( "Picked Color" .and(Space::colored(color).size(Size::squared(Lp::inches(1)))) - .into_rows(), + .and(color_as_string) + .into_rows() + .fit_horizontally(), ) .into_columns() .pad() diff --git a/src/styles.rs b/src/styles.rs index bf02a4d..1383eb4 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1852,17 +1852,17 @@ 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_hsl(self) -> Hsl; + fn into_hsla(self) -> Hsla; /// Returns the hue and saturation of this color. fn source(self) -> ColorSource { - self.into_hsl().source + self.into_hsla().hsl.source } /// Returns the perceived lightness of this color. #[must_use] fn lightness(self) -> ZeroToOne { - self.into_hsl().lightness + self.into_hsla().hsl.lightness } /// Returns the contrast between this color and the components provided. @@ -1893,7 +1893,7 @@ pub trait ColorExt: Copy { } impl ColorExt for Color { - fn into_hsl(self) -> Hsl { + fn into_hsla(self) -> Hsla { let mut hsl: palette::Okhsl = Srgb::new(self.red_f32(), self.green_f32(), self.blue_f32()).into_color(); @@ -1905,12 +1905,15 @@ impl ColorExt for Color { hsl.saturation = 0.0; } - Hsl { - source: ColorSource { - hue: hsl.hue, - saturation: ZeroToOne::new(hsl.saturation), + Hsla { + hsl: Hsl { + source: ColorSource { + hue: hsl.hue, + saturation: ZeroToOne::new(hsl.saturation), + }, + lightness: ZeroToOne::new(hsl.lightness), }, - lightness: ZeroToOne::new(hsl.lightness * self.alpha_f32()), + alpha: ZeroToOne::new(self.alpha_f32()), } } @@ -1920,15 +1923,14 @@ impl ColorExt for Color { check_lightness: ZeroToOne, check_alpha: ZeroToOne, ) -> ZeroToOne { - let other = self.into_hsl(); - let lightness_delta = other.lightness.difference_between(check_lightness); + let other = self.into_hsla(); + let lightness_delta = other.hsl.lightness.difference_between(check_lightness); - let average_lightness = ZeroToOne::new((*check_lightness + *other.lightness) / 2.); + let average_lightness = ZeroToOne::new((*check_lightness + *other.hsl.lightness) / 2.); - let source_change = check_source.contrast_between(other.source); + let source_change = check_source.contrast_between(other.hsl.source); - let other_alpha = ZeroToOne::new(self.alpha_f32()); - let alpha_delta = check_alpha.difference_between(other_alpha); + let alpha_delta = check_alpha.difference_between(other.alpha); ZeroToOne::new( (*lightness_delta @@ -1942,16 +1944,15 @@ impl ColorExt for Color { where Self: Copy, { - let check = self.into_hsl(); - let check_alpha = ZeroToOne::new(self.alpha_f32()); + let check = self.into_hsla(); 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.hsl.source, check.hsl.lightness, check.alpha); for other in others { let contrast_amount = - other.contrast_between(check.source, check.lightness, check_alpha); + other.contrast_between(check.hsl.source, check.hsl.lightness, check.alpha); if contrast_amount > most_contrast_amount { most_contrasting = other; most_contrast_amount = contrast_amount; @@ -1962,6 +1963,27 @@ impl ColorExt for Color { } } +/// A color composed of hue, saturation, and lightness. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Hsla { + /// The hue, saturation, and lightness of this color. + pub hsl: Hsl, + /// The alpha of this color. + pub alpha: ZeroToOne, +} + +impl From for Hsla { + fn from(value: Color) -> Self { + value.into_hsla() + } +} + +impl From for Color { + fn from(value: Hsla) -> Self { + Color::from(value.hsl).with_alpha_f32(*value.alpha) + } +} + /// A color composed of hue, saturation, and lightness. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Hsl { @@ -1973,7 +1995,7 @@ pub struct Hsl { impl From for Hsl { fn from(value: Color) -> Self { - value.into_hsl() + value.into_hsla().hsl } } diff --git a/src/value.rs b/src/value.rs index c8118b6..9096a74 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1828,8 +1828,10 @@ impl Drop for ChangeCallbacks { return; } Some(executing) if executing == ¤t_thread => { - tracing::warn!("Could not invoke dynamic callbacks because they are already running on this thread"); - + // The callbacks are already running, and they triggered + // again. We ignore this rather than trying to continue to + // propagate because this can only be caused by a cycle + // happening during a callback already executing. return; } Some(_) => { diff --git a/src/widgets/color.rs b/src/widgets/color.rs index 2b20dfe..f6cc408 100644 --- a/src/widgets/color.rs +++ b/src/widgets/color.rs @@ -1,22 +1,158 @@ //! Widgets for selecting colors. use std::ops::Range; -use figures::units::{Lp, Px}; +use figures::units::{Lp, Px, UPx}; use figures::{FloatConversion, Point, Rect, Round, ScreenScale, Size, Zero}; use intentional::Cast; use kludgine::app::winit::event::MouseButton; 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::animation::{LinearInterpolate, PercentBetween, ZeroToOne}; +use crate::context::{EventContext, GraphicsContext, LayoutContext}; use crate::styles::components::{HighlightColor, OutlineColor, TextColor}; -use crate::styles::{ColorExt, ColorSource, Hsl}; -use crate::value::{Destination, Dynamic, ForEachCloned, IntoDynamic, IntoValue, Source, Value}; +use crate::styles::{ColorExt, ColorSource, Hsl, Hsla}; +use crate::value::{ + Destination, Dynamic, ForEachCloned, IntoDynamic, IntoReadOnly, IntoValue, MapEach, ReadOnly, + Source, Value, +}; use crate::widget::{ EventHandling, MakeWidget, MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag, HANDLED, }; use crate::window::DeviceId; +use crate::ConstraintLimit; + +/// A [`Color`] picker that allows selecting a color using individual red, +/// green, blue, and alpha [`ComponentPicker`]s. +pub struct RgbaPicker { + color: Dynamic, +} + +impl RgbaPicker { + /// Returns a new picker that updates `color` when a new color is selected. + pub fn new(color: impl IntoDynamic) -> Self { + Self { + color: color.into_dynamic(), + } + } +} + +impl MakeWidgetWithTag for RgbaPicker { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + let red = self.color.map_each_cloned(Color::red); + let green = self.color.map_each_cloned(Color::green); + let blue = self.color.map_each_cloned(Color::blue); + let alpha = self.color.map_each_cloned(Color::alpha); + + let color = self.color.clone(); + (&red, &green, &blue, &alpha) + .for_each_cloned(move |(red, green, blue, alpha)| { + color.set(Color::new(red, green, blue, alpha)); + }) + .persist(); + + let red_picker = ComponentPicker::red(red); + let green_picker = ComponentPicker::green(green); + let blue_picker = ComponentPicker::blue(blue); + let alpha_picker = ComponentPicker::alpha(alpha, self.color); + + red_picker + .and(green_picker) + .and(blue_picker) + .and(alpha_picker) + .into_rows() + .make_with_tag(tag) + } +} + +/// A [`Color`] picker that allows selecting a color using individual red, +/// green, and blue [`ComponentPicker`]s. +pub struct RgbPicker { + color: Dynamic, +} + +impl RgbPicker { + /// Returns a new picker that updates `color` when a new color is selected. + pub fn new(color: impl IntoDynamic) -> Self { + Self { + color: color.into_dynamic(), + } + } +} + +impl MakeWidgetWithTag for RgbPicker { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + let red = self.color.map_each_cloned(Color::red); + let green = self.color.map_each_cloned(Color::green); + let blue = self.color.map_each_cloned(Color::blue); + + (&red, &green, &blue) + .for_each_cloned(move |(red, green, blue)| { + self.color.set(Color::new(red, green, blue, 255)); + }) + .persist(); + + let red_picker = ComponentPicker::red(red); + let green_picker = ComponentPicker::green(green); + let blue_picker = ComponentPicker::blue(blue); + + red_picker + .and(green_picker) + .and(blue_picker) + .into_rows() + .make_with_tag(tag) + } +} + +/// A picker for an [`Hsla`] color. +#[derive(Debug)] +pub struct HslaPicker { + source: Dynamic, + lightness: Dynamic, + alpha: Dynamic, +} + +impl HslaPicker { + /// Returns a new color picker that updates `hsla` when a new value is + /// chosen. + #[must_use] + pub fn new(hsla: Dynamic) -> Self { + let source = hsla.map_each(|hsla| hsla.hsl.source); + let lightness = hsla.map_each(|hsla| hsla.hsl.lightness); + let alpha = hsla.map_each(|hsla| hsla.alpha); + + (&source, &lightness, &alpha) + .for_each_cloned(move |(source, lightness, alpha)| { + hsla.set(Hsla { + hsl: Hsl { source, lightness }, + alpha, + }); + }) + .persist(); + + Self { + source, + lightness, + alpha, + } + } +} + +impl MakeWidgetWithTag for HslaPicker { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + let preview_color = (&self.source, &self.lightness) + .map_each(|(source, lightness)| source.color(*lightness)); + ColorSourcePicker::new(self.source) + .lightness(self.lightness.clone()) + .make_with_tag(tag) + .expand() + .and(ComponentPicker::lightness(self.lightness)) + .and(ComponentPicker::alpha_f32(self.alpha, preview_color)) + .into_rows() + .gutter(Px::ZERO) + .make_widget() + } +} /// A color picker that selects an [`Hsl`] color. #[derive(Debug)] @@ -47,28 +183,270 @@ impl MakeWidgetWithTag for HslPicker { .lightness(self.lightness.clone()) .make_with_tag(tag) .expand() - .and(LightnessPicker::new(self.lightness).height(Lp::points(24))) + .and(ComponentPicker::lightness(self.lightness)) .into_rows() .gutter(Px::ZERO) .make_widget() } } +/// A component that can be picked in a [`ComponentPicker`]. +pub trait ColorComponent: std::fmt::Debug + Send + 'static { + /// Returns the color to display at the start of the component picker. + fn start_color(&self) -> Color; + /// Returns the color to display at the end of the component picker. + fn end_color(&self) -> Color; + /// Interpolate the color at the given percentage between the start and end + /// colors. + fn interpolate_color(&self, percent: ZeroToOne) -> Color; + + /// Returns the color to to display within the loupe. + fn loupe_color(&self, percent: ZeroToOne) -> Option { + Some(self.interpolate_color(percent)) + } + + /// Draws the background behind the color component. + #[allow(unused_variables)] + fn draw_background(&self, rect: Rect, graphics: &mut GraphicsContext<'_, '_, '_, '_>) {} +} + +/// A [`ColorComponent`] that configures a [`ComponentPicker`] to pick the +/// "lightness" of a color. +/// +/// The lightness component comes from computing the light's hue, saturation, +/// and lightness (HSL) components. +#[derive(Debug)] +pub struct Lightness; + +impl ColorComponent for Lightness { + fn start_color(&self) -> Color { + Color::BLACK + } + + fn end_color(&self) -> Color { + Color::WHITE + } + + fn interpolate_color(&self, percent: ZeroToOne) -> Color { + ColorSource::new(0., 0.).color(percent) + } +} + +/// A [`ColorComponent`] that configures a [`ComponentPicker`] to pick the +/// red copmponent of a color. +#[derive(Debug)] +pub struct Red; + +impl Red { + fn build_color(red: ZeroToOne) -> Color { + Color::new_f32(*red, 0., 0., 1.) + } +} + +impl ColorComponent for Red { + fn start_color(&self) -> Color { + Self::build_color(ZeroToOne::ZERO) + } + + fn end_color(&self) -> Color { + Self::build_color(ZeroToOne::ONE) + } + + fn interpolate_color(&self, percent: ZeroToOne) -> Color { + Self::build_color(percent) + } +} + +/// A [`ColorComponent`] that configures a [`ComponentPicker`] to pick the +/// green copmponent of a color. +#[derive(Debug)] +pub struct Green; + +impl Green { + fn build_color(green: ZeroToOne) -> Color { + Color::new_f32(0., *green, 0., 1.) + } +} + +impl ColorComponent for Green { + fn start_color(&self) -> Color { + Self::build_color(ZeroToOne::ZERO) + } + + fn end_color(&self) -> Color { + Self::build_color(ZeroToOne::ONE) + } + + fn interpolate_color(&self, percent: ZeroToOne) -> Color { + Self::build_color(percent) + } +} + +/// A [`ColorComponent`] that configures a [`ComponentPicker`] to pick the +/// blue copmponent of a color. +#[derive(Debug)] +pub struct Blue; + +impl Blue { + fn build_color(blue: ZeroToOne) -> Color { + Color::new_f32(0., 0., *blue, 1.) + } +} + +impl ColorComponent for Blue { + fn start_color(&self) -> Color { + Self::build_color(ZeroToOne::ZERO) + } + + fn end_color(&self) -> Color { + Self::build_color(ZeroToOne::ONE) + } + + fn interpolate_color(&self, percent: ZeroToOne) -> Color { + Self::build_color(percent) + } +} + +/// A [`ColorComponent`] that configures a [`ComponentPicker`] to pick the alpha +/// copmponent of a color. +#[derive(Debug)] +pub struct Alpha { + color: ReadOnly, +} + +impl Alpha { + fn build_color(&self, alpha: ZeroToOne) -> Color { + self.color.get().with_alpha_f32(*alpha) + } +} + +impl ColorComponent for Alpha { + fn start_color(&self) -> Color { + self.build_color(ZeroToOne::ZERO) + } + + fn end_color(&self) -> Color { + self.build_color(ZeroToOne::ONE) + } + + fn interpolate_color(&self, percent: ZeroToOne) -> Color { + self.build_color(percent) + } + + fn loupe_color(&self, _percent: ZeroToOne) -> Option { + None + } + + fn draw_background(&self, rect: Rect, context: &mut GraphicsContext<'_, '_, '_, '_>) { + let checker_size = Lp::points(8).into_px(context.gfx.scale()).ceil(); + let shape = Shape::filled_rect( + Size::squared(checker_size).into(), + context.theme().surface.on_color.with_alpha_f32(0.1), + ); + let mut y = Px::ZERO; + let mut offset = false; + while y < rect.size.height { + let mut x = if offset { checker_size } else { Px::ZERO }; + while x < rect.size.width { + context + .gfx + .draw_shape(shape.translate_by(rect.origin + Point::new(x, y))); + x += checker_size * 2; + } + y += checker_size; + offset = !offset; + } + } +} + /// A widget that selects between completely dark and completely light by /// utilizing a back-to-white gradient. #[derive(Debug)] -pub struct LightnessPicker { +pub struct ComponentPicker { value: Dynamic, visible_rect: Rect, + component: Component, } -impl LightnessPicker { +impl ComponentPicker { /// Returns a new picker that updates `value` when a new lightness is /// selected. - pub fn new(value: impl IntoDynamic) -> Self { + pub fn lightness(value: impl IntoDynamic) -> Self { + Self::new(value, Lightness) + } +} + +impl ComponentPicker { + /// Returns a new picker that updates `value` when a new red is selected. + pub fn red(value: impl IntoDynamic) -> Self { + Self::new( + value.into_dynamic().linked( + |value| value.percent_between(&0, &255), + |percent| 0.lerp(&255, **percent), + ), + Red, + ) + } +} + +impl ComponentPicker { + /// Returns a new picker that updates `value` when a new green is selected. + pub fn green(value: impl IntoDynamic) -> Self { + Self::new( + value.into_dynamic().linked( + |value| value.percent_between(&0, &255), + |percent| 0.lerp(&255, **percent), + ), + Green, + ) + } +} + +impl ComponentPicker { + /// Returns a new picker that updates `value` when a new blue is selected. + pub fn blue(value: impl IntoDynamic) -> Self { + Self::new( + value.into_dynamic().linked( + |value| value.percent_between(&0, &255), + |percent| 0.lerp(&255, **percent), + ), + Blue, + ) + } +} + +impl ComponentPicker { + /// Returns a new picker that updates `value` when a new blue is selected. + pub fn alpha(value: impl IntoDynamic, preview_color: impl IntoReadOnly) -> Self { + Self::alpha_f32( + value.into_dynamic().linked( + |value| value.percent_between(&0, &255), + |percent| 0.lerp(&255, **percent), + ), + preview_color, + ) + } + + /// Returns a new picker that updates `value` when a new blue is selected. + pub fn alpha_f32( + value: impl IntoDynamic, + preview_color: impl IntoReadOnly, + ) -> Self { + Self::new( + value, + Alpha { + color: preview_color.into_read_only(), + }, + ) + } +} + +impl ComponentPicker { + fn new(value: impl IntoDynamic, component: Component) -> Self { Self { value: value.into_dynamic(), visible_rect: Rect::default(), + component, } } @@ -82,7 +460,28 @@ impl LightnessPicker { } } -impl Widget for LightnessPicker { +impl Widget for ComponentPicker +where + Component: ColorComponent, +{ + fn layout( + &mut self, + available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_>, + ) -> Size { + let ideal_height = Lp::points(24).into_upx(context.gfx.scale()).ceil(); + Size::new( + match available_space.width { + ConstraintLimit::Fill(width) => width, + ConstraintLimit::SizeToFit(max_width) => max_width.min(ideal_height * 4), + }, + match available_space.height { + ConstraintLimit::Fill(height) => height, + ConstraintLimit::SizeToFit(max_height) => max_height.min(ideal_height), + }, + ) + } + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { const STEPS: u8 = 10; let loupe_size = Lp::mm(3).into_px(context.gfx.scale()); @@ -112,13 +511,14 @@ impl Widget for LightnessPicker { 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; + self.component.draw_background(self.visible_rect, context); + let mut gray = self.component.start_color(); for step in 0..STEPS { let (end_x, end_gray) = if step == STEPS - 1 { - (bottom_right.x, Color::WHITE) + (bottom_right.x, self.component.end_color()) } else { lightness = ZeroToOne::new(*lightness + step_lightness); - (x + step_width, ColorSource::new(0., 0.).color(lightness)) + (x + step_width, self.component.interpolate_color(lightness)) }; context.gfx.draw_shape( &PathBuilder::new((Point::new(x, top), gray)) @@ -143,14 +543,19 @@ impl Widget for LightnessPicker { 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)]); + let selected_color = self.component.loupe_color(value); + let loupe_color = if let Some(selected_color) = selected_color { + context.gfx.draw_shape(&Shape::filled_round_rect( + loupe_rect, + CornerRadii::from(loupe_size), + selected_color, + )); + + selected_color.most_contrasting(&[context.get(&OutlineColor), context.get(&TextColor)]) + } else { + context.get(&OutlineColor) + }; + context.gfx.draw_shape(&Shape::stroked_round_rect( loupe_rect, CornerRadii::from(loupe_size),