cushy/src/widgets/color.rs
Jonathan Johnson 246352fed2
Added HslPicker
2024-01-10 08:17:09 -08:00

398 lines
13 KiB
Rust

//! Widgets for selecting colors.
use std::ops::Range;
use figures::units::{Lp, Px};
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::styles::components::{HighlightColor, OutlineColor, TextColor};
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 {
/// The currently selected hue and saturation.
pub value: Dynamic<ColorSource>,
/// The lightness value to render the color at.
pub lightness: Value<ZeroToOne>,
visible_rect: Rect<Px>,
hue_is_360: bool,
}
impl ColorSourcePicker {
/// Returns a new color picker that updates `value` when a new value is
/// selected.
#[must_use]
pub fn new(value: impl IntoDynamic<ColorSource>) -> Self {
Self {
value: value.into_dynamic(),
lightness: Value::Constant(ZeroToOne::new(0.5)),
visible_rect: Rect::default(),
hue_is_360: false,
}
}
/// Sets the ligntness to render the color picker using.
#[must_use]
pub fn lightness(mut self, lightness: impl IntoValue<ZeroToOne>) -> Self {
self.lightness = lightness.into_value();
self
}
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 (is_360, hue) = if relative.x == self.visible_rect.size.width {
(true, 360.)
} else {
(
false,
relative.x.into_float() / self.visible_rect.size.width.into_float() * 360.,
)
};
self.hue_is_360 = is_360;
let saturation =
ZeroToOne::new(relative.y.into_float() / self.visible_rect.size.height.into_float())
.one_minus();
self.value.set(ColorSource::new(hue, saturation));
}
}
impl Widget for ColorSourcePicker {
fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) {
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(
if self.hue_is_360 {
self.visible_rect.size.width
} else {
self.visible_rect.size.width * value.hue.into_positive_degrees() / 360.
},
self.visible_rect.size.height * *value.saturation.one_minus(),
);
let lightness = self.lightness.get_tracking_redraw(context);
let value_color = value.color(lightness);
let max_steps = (self.visible_rect.size.width / 2).floor().get();
let steps = (self.visible_rect.size.width / 2)
.floor()
.get()
.min(max_steps);
let step_size = self.visible_rect.size.width / steps;
let hue_step_size = 360. / steps.cast::<f32>();
let mut x = self.visible_rect.origin.x;
let mut hue = 0.;
for step in 0..steps {
let end = if step == steps - 1 {
self.visible_rect.origin.x + self.visible_rect.size.width
} else {
x + step_size
};
let end_hue = hue + hue_step_size;
draw_gradient_segment(
Point::new(x, self.visible_rect.origin.y),
end,
self.visible_rect.size.height,
hue..end_hue,
lightness,
context,
);
x = end;
hue = end_hue;
}
context.gfx.draw_shape(&Shape::stroked_rect(
self.visible_rect.inset(-options.line_width / 2),
options,
));
// Draw the loupe
context.gfx.draw_shape(
Shape::filled_circle(loupe_size / 2, value_color, Origin::Center)
.translate_by(value_pos),
);
let loupe_color = value_color.most_contrasting(&[outline_color, context.get(&TextColor)]);
context.gfx.draw_shape(
Shape::stroked_circle(loupe_size / 2, Origin::Center, options.colored(loupe_color))
.translate_by(value_pos),
);
}
fn hit_test(&mut self, location: Point<Px>, _context: &mut EventContext<'_>) -> bool {
self.visible_rect.contains(location)
}
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 draw_gradient_segment(
start: Point<Px>,
end: Px,
height: Px,
hue: Range<f32>,
lightness: ZeroToOne,
context: &mut GraphicsContext<'_, '_, '_, '_>,
) {
let vertical_slices = (height.get() / 16).clamp(3, 10);
let slice_neight = height / vertical_slices;
let slice_saturation = 1.0 / vertical_slices.cast::<f32>();
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((
Point::new(start.x, y),
ColorSource::new(hue.start, saturation).color(lightness),
))
.line_to((
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;
}
}