mirror of
https://github.com/danbulant/cushy
synced 2026-06-17 21:41:11 +00:00
Added HslPicker
This commit is contained in:
parent
6ad6cca32d
commit
246352fed2
5 changed files with 304 additions and 76 deletions
|
|
@ -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
27
examples/color-pickers.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue