diff --git a/Cargo.lock b/Cargo.lock index d8fd50b..0d80e45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,7 +1062,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#6dc4c1c7901ca8a76148d6efe2b52961352efdc1" +source = "git+https://github.com/khonsulabs/kludgine#7e9ed8130440d67d3f3371b581de9a584859ed8b" dependencies = [ "ahash", "alot", diff --git a/examples/theme.rs b/examples/theme.rs index 628690f..d57e004 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -8,6 +8,7 @@ use gooey::styles::{ use gooey::value::{Dynamic, MapEachCloned}; use gooey::widget::MakeWidget; use gooey::widgets::checkbox::Checkable; +use gooey::widgets::color::ColorSourcePicker; use gooey::widgets::input::InputValue; use gooey::widgets::slider::Slidable; use gooey::widgets::Space; @@ -17,65 +18,6 @@ use kludgine::figures::units::Lp; use kludgine::Color; use palette::OklabHue; -struct Scheme { - primary: Primary, - secondary: Other, - tertiary: Other, - error: Other, - neutral: Other, - neutral_variant: Other, -} - -impl From for Scheme { - fn from(scheme: ColorScheme) -> Self { - Self { - primary: scheme.primary, - secondary: scheme.secondary, - tertiary: scheme.tertiary, - error: scheme.error, - neutral: scheme.neutral, - neutral_variant: scheme.neutral_variant, - } - } -} - -impl Scheme { - pub fn map(&self, mut map: impl FnMut(T) -> R) -> Scheme - where - T: Clone, - { - Scheme { - primary: map(self.primary.clone()), - secondary: map(self.secondary.clone()), - tertiary: map(self.tertiary.clone()), - error: map(self.error.clone()), - neutral: map(self.neutral.clone()), - neutral_variant: map(self.neutral_variant.clone()), - } - } -} - -impl Scheme { - pub fn map_labeled( - &self, - primary: impl FnOnce(Primary) -> NewPrimary, - mut map: impl FnMut(&str, Other) -> NewOther, - ) -> Scheme - where - Primary: Clone, - Other: Clone, - { - Scheme { - primary: primary(self.primary.clone()), - secondary: map("Secondary", self.secondary.clone()), - tertiary: map("Tertiary", self.tertiary.clone()), - error: map("Error", self.error.clone()), - neutral: map("Netural", self.neutral.clone()), - neutral_variant: map("Neutral Variant", self.neutral_variant.clone()), - } - } -} - fn main() -> gooey::Result { let gooey = Gooey::default(); @@ -152,6 +94,7 @@ fn main() -> gooey::Result { } })) .into_rows() + .pad() .vertical_scroll(); editors @@ -177,6 +120,65 @@ fn main() -> gooey::Result { .run() } +struct Scheme { + primary: Primary, + secondary: Other, + tertiary: Other, + error: Other, + neutral: Other, + neutral_variant: Other, +} + +impl From for Scheme { + fn from(scheme: ColorScheme) -> Self { + Self { + primary: scheme.primary, + secondary: scheme.secondary, + tertiary: scheme.tertiary, + error: scheme.error, + neutral: scheme.neutral, + neutral_variant: scheme.neutral_variant, + } + } +} + +impl Scheme { + pub fn map(&self, mut map: impl FnMut(T) -> R) -> Scheme + where + T: Clone, + { + Scheme { + primary: map(self.primary.clone()), + secondary: map(self.secondary.clone()), + tertiary: map(self.tertiary.clone()), + error: map(self.error.clone()), + neutral: map(self.neutral.clone()), + neutral_variant: map(self.neutral_variant.clone()), + } + } +} + +impl Scheme { + pub fn map_labeled( + &self, + primary: impl FnOnce(Primary) -> NewPrimary, + mut map: impl FnMut(&str, Other) -> NewOther, + ) -> Scheme + where + Primary: Clone, + Other: Clone, + { + Scheme { + primary: primary(self.primary.clone()), + secondary: map("Secondary", self.secondary.clone()), + tertiary: map("Tertiary", self.tertiary.clone()), + error: map("Error", self.error.clone()), + neutral: map("Netural", self.neutral.clone()), + neutral_variant: map("Neutral Variant", self.neutral_variant.clone()), + } + } +} + fn dark_mode_picker() -> (Dynamic, impl MakeWidget) { let dark = Dynamic::new(true); let theme_mode = dark.map_each(|dark| { @@ -237,7 +239,10 @@ fn color_editor(color: &Dynamic) -> impl MakeWidget { .persist(); let saturation_text = saturation.linked_string(); - hue.slider_between(0., 359.99) + ColorSourcePicker::new(color.clone()) + .height(Lp::points(100)) + .fit_horizontally() + .and(hue.slider_between(0., 360.)) .and(hue_text.into_input()) .and(saturation.slider()) .and(saturation_text.into_input()) @@ -279,6 +284,7 @@ fn fixed_theme(theme: Dynamic, label: &str) -> impl MakeWidget { color, )) .into_columns() + .expand() .contain() .expand() } diff --git a/src/widgets.rs b/src/widgets.rs index 09c6057..d3dbbf9 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -5,6 +5,7 @@ pub mod button; mod canvas; pub mod checkbox; mod collapse; +pub mod color; pub mod container; mod custom; mod data; diff --git a/src/widgets/color.rs b/src/widgets/color.rs new file mode 100644 index 0000000..7bc7745 --- /dev/null +++ b/src/widgets/color.rs @@ -0,0 +1,229 @@ +//! Widgets for selecting colors. +use std::ops::Range; + +use intentional::Cast; +use kludgine::app::winit::event::{DeviceId, MouseButton}; +use kludgine::figures::units::{Lp, Px}; +use kludgine::figures::{FloatConversion, Point, Rect, Round, ScreenScale, Zero}; +use kludgine::shapes::{self, 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::{Dynamic, IntoValue, Value}; +use crate::widget::{EventHandling, Widget, HANDLED}; + +/// A widget that selects a [`ColorSource`]. +#[derive(Debug)] +pub struct ColorSourcePicker { + /// The currently selected hue and saturation. + pub value: Dynamic, + /// The lightness value to render the color at. + pub lightness: Value, + visible_rect: Rect, + 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: Dynamic) -> Self { + Self { + value, + 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) -> Self { + self.lightness = lightness.into_value(); + self + } + + 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 (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 value = self.value.get_tracking_refresh(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_tracked(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() + .get() + .min(max_steps); + + let step_size = self.visible_rect.size.width / steps; + let hue_step_size = 360. / steps.cast::(); + + 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, 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, _context: &mut EventContext<'_, '_>) -> bool { + self.visible_rect.contains(location) + } + + 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 draw_gradient_segment( + start: Point, + end: Px, + height: Px, + hue: Range, + 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), + ); + + 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), + ), + ); + + 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), + )) + .line_to(( + Point::new(start.x, start.y + height), + ColorSource::new(hue.start, ZeroToOne::ZERO).color(lightness), + )) + .close() + .fill_opt( + Color::WHITE, + &FillOptions::DEFAULT.with_sweep_orientation(shapes::Orientation::Horizontal), + ), + ); +}