Added HslPicker

This commit is contained in:
Jonathan Johnson 2024-01-10 08:17:09 -08:00
parent 6ad6cca32d
commit 246352fed2
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
5 changed files with 304 additions and 76 deletions

View file

@ -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

27
examples/color-pickers.rs Normal file
View file

@ -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()
}

View file

@ -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]: <https://docs.rs/kludgine/latest/kludgine/text/struct.MeasuredText.html>
[windowlocal]: <{{ docs }}/window/struct.WindowLocal.html>
[constraintlimit-min]: <{{ docs }}/enum.ConstraintLimit.html#method.min>

View file

@ -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:
// <https://github.com/Ogeon/palette/issues/368>
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<Color> for Hsl {
fn from(value: Color) -> Self {
value.into_hsl()
}
}
impl From<Hsl> 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 {

View file

@ -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<ColorSource>,
lightness: Dynamic<ZeroToOne>,
}
impl HslPicker {
/// Returns a new color picker that updates `hsl` when a new value is
/// chosen.
#[must_use]
pub fn new(hsl: Dynamic<Hsl>) -> 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<ZeroToOne>,
visible_rect: Rect<Px>,
}
impl LightnessPicker {
/// Returns a new picker that updates `value` when a new lightness is
/// selected.
pub fn new(value: impl IntoDynamic<ZeroToOne>) -> Self {
Self {
value: value.into_dynamic(),
visible_rect: Rect::default(),
}
}
fn update_from_mouse(&mut self, location: Point<Px>) {
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<Px>,
_device_id: DeviceId,
_button: MouseButton,
_context: &mut EventContext<'_>,
) -> EventHandling {
self.update_from_mouse(location);
HANDLED
}
fn mouse_drag(
&mut self,
location: Point<Px>,
_device_id: DeviceId,
_button: MouseButton,
_context: &mut EventContext<'_>,
) {
self.update_from_mouse(location);
}
fn hit_test(&mut self, _location: Point<Px>, _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<ColorSource>) -> Self {
pub fn new(value: impl IntoDynamic<ColorSource>) -> 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::<f32>();
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;
}
}