From a3e69534ab1f5bae2b825d02bb4bded2ac3a931d Mon Sep 17 00:00:00 2001 From: Freja Roberts Date: Fri, 13 Dec 2024 00:27:10 +0100 Subject: [PATCH] feat: new colorpicker --- colors.json | 382 +++++----- examples/colorpicker.rs | 528 +------------ violet-core/src/widget/container.rs | 5 + violet-core/src/widget/interactive/button.rs | 17 + violet-core/src/widget/interactive/slider.rs | 15 +- violet-demo/src/colorpicker.rs | 744 +++++++++++++++++++ violet-demo/src/lib.rs | 8 +- violet-demo/src/palettes/editor.rs | 172 ----- violet-demo/src/palettes/menu.rs | 167 ----- violet-demo/src/palettes/mod.rs | 392 ---------- 10 files changed, 985 insertions(+), 1445 deletions(-) create mode 100644 violet-demo/src/colorpicker.rs delete mode 100644 violet-demo/src/palettes/editor.rs delete mode 100644 violet-demo/src/palettes/menu.rs delete mode 100644 violet-demo/src/palettes/mod.rs diff --git a/colors.json b/colors.json index a1029c7..49aa4a9 100644 --- a/colors.json +++ b/colors.json @@ -1,184 +1,202 @@ { - "stone": { - "50": "#e8e8e8", - "100": "#dbdbdb", - "200": "#c1c1c1", - "300": "#a8a8a8", - "400": "#8f8f8f", - "500": "#777777", - "600": "#606060", - "700": "#4a4a4a", - "800": "#353535", - "900": "#222222", - "950": "#181818" - }, - "platinum": { - "50": "#e9e7e6", - "100": "#dcdad9", - "200": "#c3c0be", - "300": "#aba7a4", - "400": "#938e8a", - "500": "#7c7671", - "600": "#645f5b", - "700": "#4d4a47", - "800": "#373533", - "900": "#232120", - "950": "#191817" - }, - "zinc": { - "50": "#e7e8eb", - "100": "#d9dbde", - "200": "#bfc1c6", - "300": "#a5a7af", - "400": "#8b8f99", - "500": "#737782", - "600": "#5d606a", - "700": "#484a51", - "800": "#343539", - "900": "#212224", - "950": "#17181a" - }, - "cherry": { - "50": "#f8e1e5", - "100": "#edd3d7", - "200": "#dab6bc", - "300": "#cb98a1", - "400": "#bd7986", - "500": "#a95d6d", - "600": "#8a4a58", - "700": "#673c44", - "800": "#482c32", - "900": "#2e1c1f", - "950": "#221316" - }, - "copper": { - "50": "#f6e4d9", - "100": "#ebd6c9", - "200": "#d8baa8", - "300": "#c89d83", - "400": "#b9805c", - "500": "#a5663b", - "600": "#87512e", - "700": "#654029", - "800": "#472f20", - "900": "#2d1e14", - "950": "#21150d" - }, - "redwood": { - "50": "#ffded9", - "100": "#facfca", - "200": "#ebb0a9", - "300": "#e18e84", - "400": "#d9685e", - "500": "#c6463e", - "600": "#a23730", - "700": "#79302a", - "800": "#542521", - "900": "#361815", - "950": "#28100e" - }, - "amber": { - "50": "#f7e6c3", - "100": "#ecd9af", - "200": "#d9be80", - "300": "#caa13e", - "400": "#be8400", - "500": "#aa6800", - "600": "#8b5300", - "700": "#684200", - "800": "#493100", - "900": "#2e1f00", - "950": "#221600" - }, - "rose": { - "50": "#fddeeb", - "100": "#f3cfde", - "200": "#e2b1c6", - "300": "#d58faf", - "400": "#ca6b99", - "500": "#b64c82", - "600": "#953c69", - "700": "#6f3250", - "800": "#4e2739", - "900": "#311824", - "950": "#25111a" - }, - "forest": { - "50": "#dcf0c9", - "100": "#cce4b6", - "200": "#adce8c", - "300": "#8ab954", - "400": "#65a400", - "500": "#478d00", - "600": "#377300", - "700": "#2f5700", - "800": "#243e00", - "900": "#172701", - "950": "#0f1d00" - }, - "emerald": { - "50": "#baf7e4", - "100": "#a3ecd6", - "200": "#66d9ba", - "300": "#00c89d", - "400": "#00b881", - "500": "#00a267", - "600": "#008453", - "700": "#006441", - "800": "#004630", - "900": "#002d1e", - "950": "#002115" - }, - "teal": { - "50": "#b3f7f5", - "100": "#9becea", - "200": "#53d8d6", - "300": "#00c7c6", - "400": "#00b5b8", - "500": "#009fa5", - "600": "#008187", - "700": "#006265", - "800": "#004546", - "900": "#002c2d", - "950": "#002121" - }, - "ocean": { - "50": "#d0e9ff", - "100": "#bedcff", - "200": "#98c1ff", - "300": "#6ba4ff", - "400": "#3683ff", - "500": "#0060ff", - "600": "#004de2", - "700": "#0d40a4", - "800": "#11316f", - "900": "#0b1f47", - "950": "#061636" - }, - "violet": { - "50": "#ece0ff", - "100": "#e0d1ff", - "200": "#c9b2fd", - "300": "#b490fc", - "400": "#a267ff", - "500": "#8e3ef3", - "600": "#7430c8", - "700": "#572e92", - "800": "#3d2564", - "900": "#261840", - "950": "#1c1030" - }, - "amethyst": { - "50": "#f5deff", - "100": "#eacefc", - "200": "#d6afef", - "300": "#c68be8", - "400": "#b762e5", - "500": "#a43bd6", - "600": "#852daf", - "700": "#642b81", - "800": "#462359", - "900": "#2c1639", - "950": "#210f2b" - } + "palettes": [ + { + "colors": [ + { + "OkLab": { + "l": 0.8, + "chroma": 0.056842107, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.725, + "chroma": 0.08683417, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.65000004, + "chroma": 0.13935484, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.575, + "chroma": 0.21873419, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.5, + "chroma": 0.27, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.425, + "chroma": 0.21873419, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.35000002, + "chroma": 0.13935484, + "hue": 0.0 + } + }, + { + "OkLab": { + "l": 0.275, + "chroma": 0.08683417, + "hue": 0.0 + } + } + ], + "auto": { + "enabled": true, + "min_lum": 0.2, + "max_lum": 0.8, + "falloff": 15.0 + } + }, + { + "colors": [ + { + "OkLab": { + "l": 0.8, + "chroma": 0.056842107, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.725, + "chroma": 0.08683417, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.65000004, + "chroma": 0.13935484, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.575, + "chroma": 0.21873419, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.5, + "chroma": 0.27, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.425, + "chroma": 0.21873419, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.35000002, + "chroma": 0.13935484, + "hue": 60.0 + } + }, + { + "OkLab": { + "l": 0.275, + "chroma": 0.08683417, + "hue": 60.0 + } + } + ], + "auto": { + "enabled": true, + "min_lum": 0.2, + "max_lum": 0.8, + "falloff": 15.0 + } + }, + { + "colors": [ + { + "OkLab": { + "l": 0.8, + "chroma": 0.056842107, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.725, + "chroma": 0.08683417, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.65000004, + "chroma": 0.13935484, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.575, + "chroma": 0.21873419, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.5, + "chroma": 0.27, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.425, + "chroma": 0.21873419, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.35000002, + "chroma": 0.13935484, + "hue": 120.0 + } + }, + { + "OkLab": { + "l": 0.275, + "chroma": 0.08683417, + "hue": 120.0 + } + } + ], + "auto": { + "enabled": true, + "min_lum": 0.2, + "max_lum": 0.8, + "falloff": 15.0 + } + } + ] } \ No newline at end of file diff --git a/examples/colorpicker.rs b/examples/colorpicker.rs index 36d936d..05d8e17 100644 --- a/examples/colorpicker.rs +++ b/examples/colorpicker.rs @@ -1,27 +1,5 @@ -use std::{future::ready, iter::repeat, str::FromStr, sync::Arc}; - -use futures::StreamExt; -use futures_signals::signal::{Mutable, SignalExt}; -use glam::{BVec2, Vec2}; -use itertools::Itertools; -use palette::{Hsl, IntoColor, OklabHue, Oklch, RgbHue, Srgb, WithAlpha}; use tracing_subscriber::{layer::SubscriberExt, registry, util::SubscriberInitExt, EnvFilter}; use tracing_tree::HierarchicalLayer; -use violet_core::{ - layout::Align, - state::{State, StateDuplex, StateMut, StateRef, StateSink, StateStream, StateStreamRef}, - style::{ - interactive_inactive, primary_surface, spacing_medium, spacing_small, Background, SizeExt, - }, - to_owned, - unit::Unit, - utils::zip_latest, - widget::{ - card, col, header, panel, row, Button, Checkbox, InteractiveExt, Rectangle, ScrollArea, - SliderValue, SliderWithLabel, Stack, StreamWidget, TextInput, - }, - Scope, Widget, -}; pub fn main() -> anyhow::Result<()> { registry() @@ -34,509 +12,5 @@ pub fn main() -> anyhow::Result<()> { .with(EnvFilter::from_default_env()) .init(); - violet_wgpu::AppBuilder::new().run(main_app()) -} - -#[derive(Copy, Clone)] -pub struct AutoPaletteSettings { - enabled: bool, - min_lum: f32, - max_lum: f32, - falloff: f32, -} - -impl AutoPaletteSettings { - pub fn tint(&self, base_chroma: f32, color: Oklch, tint: f32) -> Oklch { - let chroma = base_chroma * (1.0 / (1.0 + self.falloff * (tint - 0.5).powi(2))); - - Oklch { - chroma, - l: (self.max_lum - self.min_lum) * (1.0 - tint) + self.min_lum, - ..color - } - } -} - -impl Default for AutoPaletteSettings { - fn default() -> Self { - Self { - enabled: true, - min_lum: 0.2, - max_lum: 0.8, - falloff: 15.0, - } - } -} - -pub fn auto_palette_settings(settings: Mutable) -> impl Widget { - card(row(( - Checkbox::label( - "Auto Tints", - settings.clone().map_ref(|v| &v.enabled, |v| &mut v.enabled), - ), - StreamWidget::new(settings.signal_ref(|v| v.enabled).dedupe().to_stream().map( - move |enabled| { - if enabled { - Some(row(( - col((header("Min L"), header("Max L"), header("Falloff"))), - col(( - precise_slider( - settings.clone().map_ref(|v| &v.min_lum, |v| &mut v.min_lum), - 0.0, - 1.0, - ) - .round(ROUNDING), - precise_slider( - settings.clone().map_ref(|v| &v.max_lum, |v| &mut v.max_lum), - 0.0, - 1.0, - ) - .round(ROUNDING), - precise_slider( - settings.clone().map_ref(|v| &v.falloff, |v| &mut v.falloff), - 0.0, - 30.0, - ) - .round(ROUNDING), - )), - ))) - } else { - None - } - }, - )), - ))) -} - -pub struct Palette { - colors: Vec>, - auto: Mutable, -} - -impl Palette {} - -fn palette_controls( - palettes: impl 'static + Send + Sync + StateMut, - palette_index: usize, - palette: &Palette, - set_selection: impl 'static + Send + Sync + StateDuplex, -) -> impl Widget { - let palettes = Arc::new(palettes); - let set_selection = Arc::new(set_selection); - - let add_swatch = Button::label("+").on_press({ - to_owned!(palettes, set_selection); - move |_, _| { - palettes.write_mut(|palette| { - let last = palette.colors.last().map(|v| v.get()).unwrap_or_default(); - palette.colors.push(Mutable::new(last)); - - set_selection.send((palette_index, palette.colors.len() - 1)) - }); - } - }); - - let external_settings_change = palette.auto.stream().for_each({ - to_owned!(palettes); - move |auto| { - palettes.read_ref(|palette| { - if palette.colors.is_empty() { - return; - } - - let ref_color = palette.colors.len() / 2; - - let count = palette.colors.len(); - let base_tint = ref_color as f32 / count as f32; - let new_color = palette.colors[ref_color].get().as_oklab(); - let base_chroma = - new_color.chroma * (1.0 + auto.falloff * (base_tint - 0.5).powi(2)); - - update_palette_tints(palette, auto, base_chroma, new_color, count); - }); - - async move {} - } - }); - - let widget = card(row(( - row(palette - .colors - .iter() - .enumerate() - .map(move |(i, color)| { - // let palettes = palettes.clone(); - // let set_selection = set_selection.clone(); - to_owned!(color, palettes, set_selection); - - let current_selection = set_selection.stream().map(move |v| { - let palettes = palettes.clone(); - let set_selection = set_selection.clone(); - - let is_selected = (palette_index, i) == v; - - Stack::new(( - StreamWidget::new(color.stream().map(|color| { - Rectangle::new(color.as_rgb().with_alpha(1.0)) - .with_min_size(Unit::px2(60.0, 60.0)) - .with_margin(spacing_medium()) - })), - Button::label("-") - .with_padding(spacing_small()) - .on_press(move |_, _| { - palettes.write_mut(|v| v.colors.remove(i)); - }), - )) - .with_horizontal_alignment(Align::End) - .with_background_opt(if is_selected { - Some(Background::new(interactive_inactive())) - } else { - None - }) - .with_padding(spacing_small()) - .on_press(move |_| set_selection.send((palette_index, i))) - }); - - StreamWidget::new(current_selection) - }) - .collect_vec()), - add_swatch, - ))); - - move |scope: &mut Scope| { - scope.spawn(external_settings_change); - widget.mount(scope) - } -} - -pub struct PaletteCollection { - palettes: Vec, -} - -pub fn main_app() -> impl Widget { - let palettes = Mutable::new(PaletteCollection { - palettes: vec![create_palette(0)], - }); - - let current_selection = Mutable::new((0_usize, 0_usize)); - - let palettes_widget = palettes - .clone() - .stream_ref({ - to_owned!(palettes, current_selection); - move |v| { - let values = v - .palettes - .iter() - .enumerate() - .map(|(i, palette)| { - to_owned!(palettes, current_selection); - - row((palette_controls( - palettes.map_ref(move |v| &v.palettes[i], move |v| &mut v.palettes[i]), - i, - palette, - current_selection, - ),)) - }) - .collect_vec(); - - let add_row = Button::label("+").on_press({ - to_owned!(palettes, current_selection); - move |_, _| { - let mut palettes = palettes.lock_mut(); - - let index = palettes.palettes.len(); - - palettes.palettes.push(create_palette(index)); - - current_selection.set((index, 0)); - } - }); - - col((col(values), add_row)) - } - }) - .boxed(); - - let current_selection = zip_latest( - current_selection.stream(), - palettes.signal_ref(|_| {}).to_stream(), - ) - .map({ - to_owned![palettes]; - move |((i, j), _)| { - let palettes = palettes.lock_ref(); - let palette = palettes.palettes.get(i)?; - Some(((i, j), palette.auto.clone(), palette.colors.get(j)?.clone())) - } - }) - .filter_map(ready) - .map(move |(palette_index, auto, color)| { - to_owned!(palettes, auto); - - let auto2 = auto.clone(); - let color_setter = color.map( - |v| v, - move |new_color| { - let auto = auto2.get(); - if !auto.enabled { - return new_color; - } - - let palette = &palettes.lock_ref().palettes[palette_index.0]; - - let count = palette.colors.len(); - let base_tint = palette_index.1 as f32 / count as f32; - let new_color = new_color.as_oklab(); - let base_chroma = - new_color.chroma * (1.0 + auto.falloff * (base_tint - 0.5).powi(2)); - - update_palette_tints(palette, auto, base_chroma, new_color, count); - - ColorValue::OkLab(auto.tint(base_chroma, new_color, base_tint)) - }, - ); - - row((swatch_editor(color_setter), auto_palette_settings(auto))) - }); - - panel(col(( - StreamWidget::new(current_selection), - ScrollArea::new(BVec2::new(true, true), StreamWidget::new(palettes_widget)), - ))) - .with_background(primary_surface()) - .with_maximize(Vec2::ONE) - .with_contain_margins(true) -} - -fn update_palette_tints( - palette: &Palette, - auto: AutoPaletteSettings, - base_chroma: f32, - new_color: Oklch, - count: usize, -) { - for (i, color) in palette.colors.iter().enumerate() { - color.set(ColorValue::OkLab(auto.tint( - base_chroma, - new_color, - i as f32 / count as f32, - ))); - } -} - -fn create_palette(index: usize) -> Palette { - let color = Oklch::new(0.5, 0.27, index as f32 * 60.0).into_color(); - - let num_colors = 8; - Palette { - colors: repeat(color) - .enumerate() - .map(|(i, v)| { - ColorValue::OkLab(AutoPaletteSettings::default().tint( - 0.27, - v, - i as f32 / num_colors as f32, - )) - }) - .map(Mutable::new) - .take(num_colors) - .collect_vec(), - auto: Default::default(), - } -} - -fn swatch_editor( - color: impl 'static + Send + Sync + StateDuplex, -) -> impl Widget { - let color = Arc::new(color); - - let color_swatch = color.clone().stream().map(|v| { - Rectangle::new(v.as_rgb().into_format().with_alpha(1.0)) - .with_aspect_ratio(1.0) - .with_min_size(Unit::px2(200.0, 200.0)) - .with_margin(spacing_small()) - }); - - panel(row(( - card(col(( - StreamWidget::new(color_swatch), - color_hex_editor(color.clone()), - ))), - col(( - rgb_picker(color.clone()), - hsl_picker(color.clone()), - oklab_picker(color), - )), - ))) -} - -const ROUNDING: f32 = 0.01; - -pub fn precise_slider( - value: impl 'static + Send + Sync + StateDuplex, - min: T, - max: T, -) -> SliderWithLabel -where - T: Default + FromStr + ToString + SliderValue, -{ - SliderWithLabel::new(value, min, max) - .with_scrub_mode(true) - .editable(true) -} - -fn rgb_picker(color: impl 'static + Send + Sync + StateDuplex) -> impl Widget { - let color = Arc::new( - color - .map(|v| v.as_rgb(), ColorValue::Rgb) - .map(Srgb::::from_format, |v| v.into_format()) - .memo(Default::default()), - ); - - let r = precise_slider(color.clone().map_ref(|v| &v.red, |v| &mut v.red), 0, 255); - let g = precise_slider( - color.clone().map_ref(|v| &v.green, |v| &mut v.green), - 0, - 255, - ); - let b = precise_slider(color.clone().map_ref(|v| &v.blue, |v| &mut v.blue), 0, 255); - - card(row(( - col((header("R"), header("G"), header("B"))), - col((r, g, b)), - ))) -} - -fn hsl_picker(color: impl 'static + Send + Sync + StateDuplex) -> impl Widget { - let color = Arc::new( - color - .map(|v| v.as_hsl(), ColorValue::Hsl) - .memo(Default::default()), - ); - - let hue = color - .clone() - .map_ref(|v| &v.hue, |v| &mut v.hue) - .map(|v| v.into_positive_degrees(), RgbHue::from_degrees) - .memo(Default::default()); - - let h = precise_slider(hue, 0.0, 360.0).round(1.0); - let s = precise_slider( - color - .clone() - .map_ref(|v| &v.saturation, |v| &mut v.saturation), - 0.0, - 1.0, - ) - .round(ROUNDING); - - let l = SliderWithLabel::new( - color - .clone() - .map_ref(|v| &v.lightness, |v| &mut v.lightness), - 0.0, - 1.0, - ) - .round(ROUNDING) - .editable(true); - - card(row(( - col((header("H"), header("S"), header("L"))), - col((h, s, l)), - ))) -} - -fn oklab_picker(color: impl 'static + Send + Sync + StateDuplex) -> impl Widget { - let color = Arc::new( - color - .map(|v| v.as_oklab(), ColorValue::OkLab) - .memo(Default::default()), - ); - - let hue = color - .clone() - .map_ref(|v| &v.hue, |v| &mut v.hue) - .map(|v| v.into_positive_degrees(), OklabHue::from_degrees) - .memo(Default::default()); - - let h = precise_slider(hue, 0.0, 360.0).round(1.0); - let c = precise_slider( - color.clone().map_ref(|v| &v.chroma, |v| &mut v.chroma), - 0.0, - 0.37, - ) - .round(0.001); - - let l = precise_slider(color.clone().map_ref(|v| &v.l, |v| &mut v.l), 0.0, 1.0).round(ROUNDING); - - card(row(( - col((header("L"), header("C"), header("H"))), - col((l, c, h)), - ))) -} - -pub fn color_hex(color: impl IntoColor) -> String { - let hex: Srgb = color.into_color().into_format(); - format!("#{:0>2x}{:0>2x}{:0>2x}", hex.red, hex.green, hex.blue) -} - -fn color_hex_editor( - color: impl 'static + Send + Sync + StateDuplex, -) -> impl Widget { - let color = Arc::new( - color - .map(|v| v.as_rgb(), ColorValue::Rgb) - .memo(Default::default()), - ); - - let value = color.prevent_feedback().filter_map( - |v| Some(color_hex(v)), - |v| { - let v: Srgb = v.trim().parse().ok()?; - Some(v.into_format()) - }, - ); - - TextInput::new(value) -} - -#[derive(Clone, Copy)] -enum ColorValue { - Rgb(Srgb), - Hsl(Hsl), - OkLab(Oklch), -} - -impl Default for ColorValue { - fn default() -> Self { - Self::Rgb(Default::default()) - } -} - -impl ColorValue { - fn as_rgb(&self) -> Srgb { - match *self { - ColorValue::Rgb(rgb) => rgb, - ColorValue::Hsl(hsl) => hsl.into_color(), - ColorValue::OkLab(lch) => lch.into_color(), - } - } - - fn as_hsl(&self) -> Hsl { - match *self { - ColorValue::Rgb(rgb) => rgb.into_color(), - ColorValue::Hsl(hsl) => hsl, - ColorValue::OkLab(lch) => lch.into_color(), - } - } - - fn as_oklab(&self) -> Oklch { - match *self { - ColorValue::Rgb(rgb) => rgb.into_color(), - ColorValue::Hsl(hsl) => hsl.into_color(), - ColorValue::OkLab(lch) => lch, - } - } + violet_wgpu::AppBuilder::new().run(violet_demo::colorpicker::main_app()) } diff --git a/violet-core/src/widget/container.rs b/violet-core/src/widget/container.rs index bb75f33..a626b84 100644 --- a/violet-core/src/widget/container.rs +++ b/violet-core/src/widget/container.rs @@ -141,6 +141,11 @@ impl List { self } + pub fn with_reverse(mut self, reverse: bool) -> Self { + self.layout.reverse = reverse; + self + } + /// Set the List's cross axis alignment pub fn with_cross_align(mut self, cross_align: Align) -> Self { self.layout.cross_align = cross_align; diff --git a/violet-core/src/widget/interactive/button.rs b/violet-core/src/widget/interactive/button.rs index a94ee23..bc82bcb 100644 --- a/violet-core/src/widget/interactive/button.rs +++ b/violet-core/src/widget/interactive/button.rs @@ -17,6 +17,7 @@ use crate::{ }; type ButtonCallback = Box, winit::event::MouseButton)>; +type ButtonClickCallback = Box)>; #[derive(Debug, Clone)] pub struct ButtonStyle { @@ -36,6 +37,7 @@ impl Default for ButtonStyle { /// A button which invokes the callback when clicked pub struct Button { on_press: ButtonCallback, + on_click: ButtonClickCallback, label: W, style: ButtonStyle, size: WidgetSize, @@ -49,6 +51,7 @@ impl Button { { Self { on_press: Box::new(|_, _| {}), + on_click: Box::new(|_| {}), label, style: Default::default(), size: WidgetSize::default() @@ -68,6 +71,12 @@ impl Button { self } + /// Handle the button press + pub fn on_click(mut self, on_press: impl 'static + Send + Sync + FnMut(&ScopeRef<'_>)) -> Self { + self.on_click = Box::new(on_press); + self + } + pub fn success(mut self) -> Self { self.style.normal_color = success_element().into(); self @@ -117,14 +126,22 @@ impl Widget for Button { let pressed_color = self.style.pressed_color.resolve(&stylesheet); let normal_color = self.style.normal_color.resolve(&stylesheet); + let mut is_pressed = false; + scope .set(focusable(), ()) .on_event(on_mouse_input(), move |scope, input| { if input.state == ElementState::Pressed { + is_pressed = true; scope.update_dedup(color(), pressed_color); (self.on_press)(scope, input.button); } else { scope.update_dedup(color(), normal_color); + + if is_pressed { + is_pressed = false; + (self.on_click)(scope); + } } }); diff --git a/violet-core/src/widget/interactive/slider.rs b/violet-core/src/widget/interactive/slider.rs index 7cdce21..9ca53ba 100644 --- a/violet-core/src/widget/interactive/slider.rs +++ b/violet-core/src/widget/interactive/slider.rs @@ -96,7 +96,11 @@ impl Widget for Slider { let handle_size = self.style.handle_size; let track_size = self.style.track_size; - let track = scope.attach(Rectangle::new(track_color).with_size(track_size)); + let track = scope.attach( + Rectangle::new(track_color) + .with_min_size(track_size) + .with_size(track_size), + ); let min = self.min.to_progress(); let max = self.max.to_progress(); @@ -349,6 +353,15 @@ impl SliderWithLabel { self.value = Arc::new(self.value.map(x, |v| v)); self } + + pub fn round_digits(mut self, round: u32) -> Self { + self.rounding = Some(10i32.pow(round) as f32); + let x = move |v: f32| (v * 10i32.pow(round) as f32).round() / 10i32.pow(round) as f32; + + self.slider.transform = Some(Box::new(x)); + self.value = Arc::new(self.value.map(x, |v| v)); + self + } } impl Widget diff --git a/violet-demo/src/colorpicker.rs b/violet-demo/src/colorpicker.rs new file mode 100644 index 0000000..eaced68 --- /dev/null +++ b/violet-demo/src/colorpicker.rs @@ -0,0 +1,744 @@ +use std::{future::ready, iter::repeat, str::FromStr, sync::Arc, time::Duration}; + +use anyhow::Context; +use futures::StreamExt; +use glam::{BVec2, Vec2}; +use itertools::Itertools; +use rfd::AsyncFileDialog; +use serde::{Deserialize, Serialize}; +use violet::core::{ + io::clipboard, + layout::Align, + state::{State, StateDuplex, StateMut, StateRef, StateSink, StateStream, StateStreamRef}, + style::{ + interactive_inactive, primary_surface, spacing_medium, spacing_small, Background, SizeExt, + }, + time::sleep, + to_owned, + unit::Unit, + utils::zip_latest, + widget::{ + card, col, header, label, panel, row, Button, Checkbox, InteractiveExt, Rectangle, + ScrollArea, SliderValue, SliderWithLabel, Stack, StreamWidget, Text, TextInput, + }, + FutureEffect, Scope, ScopeRef, Widget, +}; +use violet::futures_signals::signal::{Mutable, SignalExt}; +use violet::palette::{Hsl, IntoColor, OklabHue, Oklch, RgbHue, Srgb, WithAlpha}; + +#[derive(Clone, Copy, Serialize, Deserialize)] +pub struct AutoPaletteSettings { + enabled: bool, + min_lum: f32, + max_lum: f32, + falloff: f32, +} + +impl AutoPaletteSettings { + pub fn tint(&self, base_chroma: f32, color: Oklch, tint: f32) -> Oklch { + let chroma = base_chroma * (1.0 / (1.0 + self.falloff * (tint - 0.5).powi(2))); + + Oklch { + chroma, + l: (self.max_lum - self.min_lum) * (1.0 - tint) + self.min_lum, + ..color + } + } +} + +impl Default for AutoPaletteSettings { + fn default() -> Self { + Self { + enabled: false, + min_lum: 0.2, + max_lum: 0.8, + falloff: 15.0, + } + } +} + +pub fn auto_palette_settings(settings: Mutable) -> impl Widget { + card(row(( + Checkbox::label( + "Auto Tints", + settings.clone().map_ref(|v| &v.enabled, |v| &mut v.enabled), + ), + StreamWidget::new(settings.signal_ref(|v| v.enabled).dedupe().to_stream().map( + move |enabled| { + if enabled { + Some(row(( + col((header("Min L"), header("Max L"), header("Falloff"))), + col(( + precise_slider( + settings.clone().map_ref(|v| &v.min_lum, |v| &mut v.min_lum), + 0.0, + 1.0, + ) + .round(ROUNDING), + precise_slider( + settings.clone().map_ref(|v| &v.max_lum, |v| &mut v.max_lum), + 0.0, + 1.0, + ) + .round(ROUNDING), + precise_slider( + settings.clone().map_ref(|v| &v.falloff, |v| &mut v.falloff), + 0.0, + 30.0, + ) + .round(ROUNDING), + )), + ))) + } else { + None + } + }, + )), + ))) +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Palette { + colors: Vec>, + auto: Mutable, +} + +impl Palette {} + +fn palette_controls( + palettes: impl 'static + Send + Sync + StateMut, + palette_index: usize, + palette: &Palette, + set_selection: impl 'static + Send + Sync + StateDuplex, +) -> impl Widget { + let palettes = Arc::new(palettes); + let set_selection = Arc::new(set_selection); + + let add_swatch = Button::label("+").on_press({ + to_owned!(palettes, set_selection); + move |_, _| { + palettes.write_mut(|palette| { + let last = palette.colors.last().map(|v| v.get()).unwrap_or_default(); + palette.colors.push(Mutable::new(last)); + + set_selection.send((palette_index, palette.colors.len() - 1)) + }); + } + }); + + let external_settings_change = palette.auto.stream().for_each({ + to_owned!(palettes); + move |auto| { + palettes.read_ref(|palette| { + if palette.colors.is_empty() { + return; + } + + let ref_color = palette.colors.len() / 2; + + let count = palette.colors.len(); + if auto.enabled { + let base_tint = ref_color as f32 / count as f32; + let new_color = palette.colors[ref_color].get().as_oklab(); + let base_chroma = + new_color.chroma * (1.0 + auto.falloff * (base_tint - 0.5).powi(2)); + + update_palette_tints(palette, auto, base_chroma, new_color, count); + } + }); + + async move {} + } + }); + + let widget = card(row(( + row(palette + .colors + .iter() + .enumerate() + .map(move |(i, color)| { + to_owned!(color, palettes, set_selection); + + let current_selection = set_selection.stream().map(move |v| { + let palettes = palettes.clone(); + let set_selection = set_selection.clone(); + + let is_selected = (palette_index, i) == v; + + Stack::new(( + StreamWidget::new(color.stream().map(|color| { + Rectangle::new(color.as_rgb().with_alpha(1.0)) + .with_min_size(Unit::px2(60.0, 60.0)) + })), + Button::label("-") + .with_padding(spacing_small()) + .with_margin(spacing_small()) + .on_press(move |_, _| { + palettes.write_mut(|v| v.colors.remove(i)); + }), + )) + .with_horizontal_alignment(Align::End) + .with_background_opt(if is_selected { + Some(Background::new(interactive_inactive())) + } else { + None + }) + .with_padding(spacing_small()) + .on_press(move |_| set_selection.send((palette_index, i))) + }); + + StreamWidget::new(current_selection) + }) + .collect_vec()), + add_swatch, + ))); + + move |scope: &mut Scope| { + scope.spawn(external_settings_change); + widget.mount(scope) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct PaletteCollection { + palettes: Vec, +} + +pub fn main_app() -> impl Widget { + let palettes = Mutable::new(PaletteCollection { + palettes: vec![create_palette(0, 9)], + }); + + let current_selection = Mutable::new((0_usize, 0_usize)); + + let palettes_widget = palettes + .clone() + .stream_ref({ + to_owned!(palettes, current_selection); + move |v| { + let values = v + .palettes + .iter() + .enumerate() + .map(|(i, palette)| { + to_owned!(palettes, current_selection); + + row((palette_controls( + palettes.map_ref(move |v| &v.palettes[i], move |v| &mut v.palettes[i]), + i, + palette, + current_selection, + ),)) + }) + .collect_vec(); + + let add_row = Button::label("+").on_press({ + to_owned!(palettes, current_selection); + move |_, _| { + let mut palettes = palettes.lock_mut(); + + let num_colors = palettes + .palettes + .last() + .map(|v| v.colors.len()) + .unwrap_or(9); + + let index = palettes.palettes.len(); + + palettes.palettes.push(create_palette(index, num_colors)); + + current_selection.set((index, 0)); + } + }); + + col((col(values), add_row)) + } + }) + .boxed(); + + let current_selection = zip_latest( + current_selection.stream(), + palettes.signal_ref(|_| {}).to_stream(), + ) + .map({ + to_owned![palettes]; + move |((i, j), _)| { + let palettes = palettes.lock_ref(); + let palette = palettes.palettes.get(i)?; + Some(((i, j), palette.auto.clone(), palette.colors.get(j)?.clone())) + } + }) + .filter_map(ready) + .map({ + to_owned!(palettes); + move |(palette_index, auto, color)| { + to_owned!(palettes, auto); + + let auto2 = auto.clone(); + let color_setter = color.map( + |v| v, + move |new_color| { + let auto = auto2.get(); + if !auto.enabled { + return new_color; + } + + let palette = &palettes.lock_ref().palettes[palette_index.0]; + + let count = palette.colors.len(); + let base_tint = palette_index.1 as f32 / count as f32; + let new_color = new_color.as_oklab(); + let base_chroma = + new_color.chroma * (1.0 + auto.falloff * (base_tint - 0.5).powi(2)); + + update_palette_tints(palette, auto, base_chroma, new_color, count); + + ColorValue::OkLab(auto.tint(base_chroma, new_color, base_tint)) + }, + ); + + row((swatch_editor(color_setter), auto_palette_settings(auto))) + } + }); + + panel(col(( + export_controls(palettes), + StreamWidget::new(current_selection), + ScrollArea::new(BVec2::new(true, true), StreamWidget::new(palettes_widget)), + ))) + .with_background(primary_surface()) + .with_maximize(Vec2::ONE) + .with_contain_margins(true) +} + +fn update_palette_tints( + palette: &Palette, + auto: AutoPaletteSettings, + base_chroma: f32, + new_color: Oklch, + count: usize, +) { + for (i, color) in palette.colors.iter().enumerate() { + color.set(ColorValue::OkLab(auto.tint( + base_chroma, + new_color, + i as f32 / count as f32, + ))); + } +} + +fn create_palette(index: usize, num_colors: usize) -> Palette { + let color = Oklch::new(0.5, 0.27, index as f32 * 60.0).into_color(); + + Palette { + colors: repeat(color) + .enumerate() + .map(|(i, v)| { + ColorValue::OkLab(AutoPaletteSettings::default().tint( + 0.27, + v, + i as f32 / num_colors as f32, + )) + }) + .map(Mutable::new) + .take(num_colors) + .collect_vec(), + auto: Default::default(), + } +} + +fn swatch_editor( + color: impl 'static + Send + Sync + StateDuplex, +) -> impl Widget { + let color = Arc::new(color); + + let color_swatch = color.clone().stream().map(|v| { + Rectangle::new(v.as_rgb().into_format().with_alpha(1.0)) + .with_aspect_ratio(1.0) + .with_min_size(Unit::px2(200.0, 200.0)) + .with_margin(spacing_small()) + }); + + panel(row(( + card(col(( + StreamWidget::new(color_swatch), + color_hex_editor(color.clone()), + ))), + col(( + rgb_picker(color.clone()), + hsl_picker(color.clone()), + oklab_picker(color), + )), + ))) +} + +const ROUNDING: f32 = 0.01; + +pub fn precise_slider( + value: impl 'static + Send + Sync + StateDuplex, + min: T, + max: T, +) -> SliderWithLabel +where + T: Default + FromStr + ToString + SliderValue, +{ + SliderWithLabel::new(value, min, max) + .with_scrub_mode(true) + .editable(true) +} + +fn rgb_picker(color: impl 'static + Send + Sync + StateDuplex) -> impl Widget { + let color = Arc::new( + color + .map(|v| v.as_rgb(), ColorValue::Rgb) + .map(Srgb::::from_format, |v| v.into_format()) + .memo(Default::default()), + ); + + let r = precise_slider(color.clone().map_ref(|v| &v.red, |v| &mut v.red), 0, 255); + let g = precise_slider( + color.clone().map_ref(|v| &v.green, |v| &mut v.green), + 0, + 255, + ); + let b = precise_slider(color.clone().map_ref(|v| &v.blue, |v| &mut v.blue), 0, 255); + + card(row(( + col((header("R"), header("G"), header("B"))), + col((r, g, b)), + ))) +} + +fn hsl_picker(color: impl 'static + Send + Sync + StateDuplex) -> impl Widget { + let color = Arc::new( + color + .map(|v| v.as_hsl(), ColorValue::Hsl) + .memo(Default::default()), + ); + + let hue = color + .clone() + .map_ref(|v| &v.hue, |v| &mut v.hue) + .map(|v| v.into_positive_degrees(), RgbHue::from_degrees) + .memo(Default::default()); + + let h = precise_slider(hue, 0.0, 360.0).round(1.0); + let s = precise_slider( + color + .clone() + .map_ref(|v| &v.saturation, |v| &mut v.saturation), + 0.0, + 1.0, + ) + .round(ROUNDING); + + let l = SliderWithLabel::new( + color + .clone() + .map_ref(|v| &v.lightness, |v| &mut v.lightness), + 0.0, + 1.0, + ) + .round(ROUNDING) + .editable(true); + + card(row(( + col((header("H"), header("S"), header("L"))), + col((h, s, l)), + ))) +} + +fn oklab_picker(color: impl 'static + Send + Sync + StateDuplex) -> impl Widget { + let color = Arc::new( + color + .map(|v| v.as_oklab(), ColorValue::OkLab) + .memo(Default::default()), + ); + + let hue = color + .clone() + .map_ref(|v| &v.hue, |v| &mut v.hue) + .map(|v| v.into_positive_degrees(), OklabHue::from_degrees) + .memo(Default::default()); + + let h = precise_slider(hue, 0.0, 360.0).round(1.0); + let c = precise_slider( + color.clone().map_ref(|v| &v.chroma, |v| &mut v.chroma), + 0.0, + 0.37, + ) + .round_digits(3); + + let l = precise_slider(color.clone().map_ref(|v| &v.l, |v| &mut v.l), 0.0, 1.0).round(ROUNDING); + + card(row(( + col((header("L"), header("C"), header("H"))), + col((l, c, h)), + ))) +} + +pub fn color_hex(color: impl IntoColor) -> String { + let hex: Srgb = color.into_color().into_format(); + format!("#{:0>2x}{:0>2x}{:0>2x}", hex.red, hex.green, hex.blue) +} + +fn color_hex_editor( + color: impl 'static + Send + Sync + StateDuplex, +) -> impl Widget { + let color = Arc::new( + color + .map(|v| v.as_rgb(), ColorValue::Rgb) + .memo(Default::default()), + ); + + let value = color.prevent_feedback().filter_map( + |v| Some(color_hex(v)), + |v| { + let v = v.trim(); + + if !v.starts_with("#") || v.len() != 7 { + return None; + } + + let v: Srgb = v.parse().ok()?; + Some(v.into_format()) + }, + ); + + TextInput::new(value) +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +enum ColorValue { + Rgb(Srgb), + Hsl(Hsl), + OkLab(Oklch), +} + +impl Default for ColorValue { + fn default() -> Self { + Self::Rgb(Default::default()) + } +} + +impl ColorValue { + fn as_rgb(&self) -> Srgb { + match *self { + ColorValue::Rgb(rgb) => rgb, + ColorValue::Hsl(hsl) => hsl.into_color(), + ColorValue::OkLab(lch) => lch.into_color(), + } + } + + fn as_hsl(&self) -> Hsl { + match *self { + ColorValue::Rgb(rgb) => rgb.into_color(), + ColorValue::Hsl(hsl) => hsl, + ColorValue::OkLab(lch) => lch.into_color(), + } + } + + fn as_oklab(&self) -> Oklch { + match *self { + ColorValue::Rgb(rgb) => rgb.into_color(), + ColorValue::Hsl(hsl) => hsl.into_color(), + ColorValue::OkLab(lch) => lch, + } + } +} + +fn local_dir() -> std::path::PathBuf { + #[cfg(not(target_arch = "wasm32"))] + { + std::env::current_dir().unwrap() + } + #[cfg(target_arch = "wasm32")] + { + std::path::PathBuf::from(".") + } +} + +struct TimedWidget { + widget: W, + lifetime: Duration, +} + +impl Widget for TimedWidget { + fn mount(self, scope: &mut Scope<'_>) { + let id = scope.attach(self.widget); + + scope.spawn_effect(FutureEffect::new( + sleep(self.lifetime), + move |scope: &mut Scope, _| { + scope.detach(id); + }, + )) + } +} + +impl TimedWidget { + fn new(widget: W, lifetime: Duration) -> Self { + Self { widget, lifetime } + } +} + +pub fn export_controls(palettes: Mutable) -> impl Widget { + let (result_tx, result_rx) = flume::unbounded(); + + fn set_result(result: &flume::Sender>, text: impl Into) { + result + .send(TimedWidget::new(label(text), Duration::from_secs(5))) + .unwrap(); + } + + let save = { + to_owned!(palettes, result_tx); + move |scope: &ScopeRef| { + to_owned!(result_tx); + let data = serde_json::to_string_pretty(&palettes).unwrap(); + + let fut = async move { + let Some(file) = AsyncFileDialog::new() + .set_directory(local_dir()) + .set_file_name("colors.json") + .save_file() + .await + else { + anyhow::bail!("No file specified"); + }; + + file.write(data.as_bytes()) + .await + .context("Failed to write to save file")?; + + Ok(()) + }; + + scope.spawn(async move { + match fut.await { + Ok(_) => set_result(&result_tx, "Saved palettes"), + Err(e) => set_result(&result_tx, format!("{e:?}")), + } + }); + } + }; + + let load = { + to_owned!(palettes, result_tx); + move |scope: &ScopeRef| { + to_owned!(palettes, result_tx); + let fut = async move { + let Some(file) = AsyncFileDialog::new() + .set_directory(local_dir()) + .set_file_name("colors.json") + .pick_file() + .await + else { + anyhow::bail!("No file specified"); + }; + + let data = file.read().await; + + let data: PaletteCollection = + serde_json::from_slice(&data).context("Failed to deserialize state")?; + + let count = data.palettes.len(); + palettes.set(data); + + anyhow::Ok(count) + }; + + scope.spawn(async move { + match fut.await { + Ok(count) => set_result(&result_tx, format!("Loaded {count} palettes")), + Err(e) => set_result(&result_tx, format!("{e:?}")), + } + }); + } + }; + + let export = { + to_owned!(palettes, result_tx); + move |scope: &ScopeRef| { + to_owned!(result_tx); + let data = serde_json::to_string_pretty(&palettes.lock_ref().export()).unwrap(); + + let fut = async move { + let Some(file) = AsyncFileDialog::new() + .set_directory(local_dir()) + .set_file_name("color_palette.json") + .save_file() + .await + else { + return anyhow::Ok(()); + }; + + file.write(data.as_bytes()) + .await + .context("Failed to write to file")?; + + Ok(()) + }; + + scope.spawn(async move { + match fut.await { + Ok(_) => set_result(&result_tx, "Exported palettes"), + Err(e) => set_result(&result_tx, format!("{e:?}")), + } + }); + } + }; + + let export_clipboard = { + to_owned!(result_tx); + move |scope: &ScopeRef<'_>| { + let exported = serde_json::to_string_pretty(&palettes.lock_ref().export()).unwrap(); + + let clipboard = scope + .get_atom(clipboard()) + .expect("Clipboard not available"); + + let clipboard = scope.frame().store().get(&clipboard).clone(); + + scope.spawn(async move { clipboard.set_text(exported).await }); + set_result(&result_tx, "Copied palettes to clipboard"); + } + }; + + row(( + Button::label("Save").on_click(save), + Button::label("Load").on_click(load), + Button::label("Export").on_click(export), + Button::label("Export To Clipboard").on_click(export_clipboard), + StreamWidget::new(result_rx.into_stream()), + )) + .with_cross_align(Align::Center) +} + +#[derive(Serialize)] +pub struct PalettesExport { + palettes: Vec, +} + +#[derive(Serialize)] +pub struct PaletteExport { + colors: Vec, +} + +impl PaletteCollection { + pub fn export(&self) -> PalettesExport { + PalettesExport { + palettes: self + .palettes + .iter() + .map(|v| PaletteExport { + colors: v + .colors + .iter() + .map(|v| color_hex(v.get().as_rgb())) + .collect(), + }) + .collect_vec(), + } + } +} diff --git a/violet-demo/src/lib.rs b/violet-demo/src/lib.rs index fe6157e..ddbffb5 100644 --- a/violet-demo/src/lib.rs +++ b/violet-demo/src/lib.rs @@ -13,7 +13,7 @@ use violet::{ use wasm_bindgen_futures::wasm_bindgen; pub mod bridge_of_death; -mod palettes; +pub mod colorpicker; #[cfg(target_arch = "wasm32")] fn setup() { @@ -59,7 +59,7 @@ pub fn run() { AppBuilder::new() .with_title("Palette Editor") .with_renderer_config(MainRendererConfig { debug_mode: false }) - .run(app()) + .run(colorpicker::main_app()) .unwrap(); } @@ -69,7 +69,7 @@ enum DemoState { PaletteEditor, } -fn app() -> impl Widget { +pub fn multi_app() -> impl Widget { let state = Mutable::new(DemoState::Basic); col(( (row(( @@ -91,7 +91,7 @@ fn app() -> impl Widget { .with_maximize(Vec2::X), StreamWidget(state.stream().map(|v| match v { DemoState::Basic => bridge_of_death::app().boxed(), - DemoState::PaletteEditor => palettes::App.boxed(), + DemoState::PaletteEditor => colorpicker::main_app().boxed(), })), )) } diff --git a/violet-demo/src/palettes/editor.rs b/violet-demo/src/palettes/editor.rs deleted file mode 100644 index 3410557..0000000 --- a/violet-demo/src/palettes/editor.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::sync::Arc; - -use futures::StreamExt; -use glam::Vec2; -use violet::{ - core::{ - state::{DynStateDuplex, State, StateStream}, - style::{SizeExt, ValueOrRef}, - unit::Unit, - widget::{ - card, col, label, row, Radio, Rectangle, SliderWithLabel, StreamWidget, Text, - TextInput, WidgetExt, - }, - Scope, Widget, - }, - futures_signals::signal::Mutable, - palette::{rgb::Rgb, FromColor, IntoColor, OklabHue, Oklch, Srgb}, -}; - -use super::{color_hex, PaletteColor}; - -#[derive(Debug, Clone, Copy)] -enum EditorMode { - Oklch, - Rgb, -} - -impl EditorMode { - /// Returns `true` if the editor mode is [`Oklch`]. - /// - /// [`Oklch`]: EditorMode::Oklch - #[must_use] - fn is_oklch(&self) -> bool { - matches!(self, Self::Oklch) - } - - /// Returns `true` if the editor mode is [`Rgb`]. - /// - /// [`Rgb`]: EditorMode::Rgb - #[must_use] - fn is_rgb(&self) -> bool { - matches!(self, Self::Rgb) - } -} - -pub fn palette_editor(palette: Mutable) -> impl Widget { - let falloff = palette.clone().map_ref(|v| &v.falloff, |v| &mut v.falloff); - - let color = Arc::new(palette.clone().map_ref(|v| &v.color, |v| &mut v.color)); - let color_rect = color.stream().map(|v| { - Rectangle::new(ValueOrRef::value(v.into_color())) - .with_min_size(Unit::px2(100.0, 100.0)) - .with_maximize(Vec2::X) - // .with_min_size(Unit::new(vec2(0.0, 100.0), vec2(1.0, 0.0))) - .with_name("ColorPreview") - }); - - let current_mode = Mutable::new(EditorMode::Oklch); - - card(col(( - row(( - Radio::new( - label("Oklch"), - current_mode - .clone() - .map(|v| v.is_oklch(), |_| EditorMode::Oklch), - ), - Radio::new( - label("Rgb"), - current_mode - .clone() - .map(|v| v.is_rgb(), |_| EditorMode::Rgb), - ), - )), - StreamWidget(current_mode.stream().map(move |mode| match mode { - EditorMode::Oklch => Box::new(oklch_editor(palette.clone())) as Box, - EditorMode::Rgb => Box::new(rgb_editor(palette.clone())), - })), - ColorHexEditor { - color: Box::new(color.clone()), - }, - StreamWidget(color_rect), - row(( - Text::new("Chroma falloff"), - SliderWithLabel::new(falloff, 0.0, 100.0) - .editable(true) - .round(1.0), - )), - ))) - .with_name("PaletteEditor") -} - -pub struct ColorHexEditor { - color: DynStateDuplex, -} - -impl Widget for ColorHexEditor { - fn mount(self, scope: &mut Scope<'_>) { - let value = self.color.prevent_feedback().filter_map( - |v| Some(color_hex(v)), - |v| { - let v: Srgb = v.trim().parse().ok()?; - - let v = Oklch::from_color(v.into_format()); - Some(v) - }, - ); - - TextInput::new(value).mount(scope) - } -} - -fn oklch_editor(color: Mutable) -> impl Widget { - let color = Arc::new(color.map_ref(|v| &v.color, |v| &mut v.color)); - - let lightness = color.clone().map_ref(|v| &v.l, |v| &mut v.l); - let chroma = color.clone().map_ref(|v| &v.chroma, |v| &mut v.chroma); - let hue = color - .clone() - .map_ref(|v| &v.hue, |v| &mut v.hue) - .map(|v| v.into_positive_degrees(), OklabHue::new); - - col(( - row(( - Text::new("Lightness"), - SliderWithLabel::new(lightness, 0.0, 1.0) - .editable(true) - .round(0.01), - )), - row(( - Text::new("Chroma"), - SliderWithLabel::new(chroma, 0.0, 0.37) - .editable(true) - .round(0.005), - )), - row(( - Text::new("Hue"), - SliderWithLabel::new(hue, 0.0, 360.0) - .editable(true) - .round(1.0), - )), - )) -} - -pub fn rgb_editor(color: Mutable) -> impl Widget { - let rgb_color = Arc::new( - color - .map_ref(|v| &v.color, |v| &mut v.color) - .map(Rgb::from_color, |v: Rgb| Oklch::from_color(v)) - .memo(Default::default()), - ); - - let r = rgb_color.clone().map_ref(|v| &v.red, |v| &mut v.red); - let g = rgb_color.clone().map_ref(|v| &v.green, |v| &mut v.green); - let b = rgb_color.clone().map_ref(|v| &v.blue, |v| &mut v.blue); - - card(col(( - row(( - Text::new("Red"), - SliderWithLabel::new(r, 0.0, 1.0).editable(true).round(0.01), - )), - row(( - Text::new("Green"), - SliderWithLabel::new(g, 0.0, 1.0).editable(true).round(0.01), - )), - row(( - Text::new("Blue"), - SliderWithLabel::new(b, 0.0, 1.0).editable(true).round(0.01), - )), - ))) - .with_name("PaletteEditor") -} diff --git a/violet-demo/src/palettes/menu.rs b/violet-demo/src/palettes/menu.rs deleted file mode 100644 index 5edf053..0000000 --- a/violet-demo/src/palettes/menu.rs +++ /dev/null @@ -1,167 +0,0 @@ -use anyhow::Context; -use flume::Sender; -use futures::Future; -use heck::ToKebabCase; -use indexmap::IndexMap; -use rfd::AsyncFileDialog; -use violet::{ - core::{ - to_owned, - widget::{centered, label, row, Button}, - Widget, - }, - futures_signals::signal::Mutable, - palette::{FromColor, Srgb}, -}; - -use super::{local_dir, HexColor, Notification, NotificationKind, PaletteColor, TINTS}; - -async fn notify_result( - fut: impl Future>, - notify_tx: Sender, - on_success: &str, -) { - match fut.await { - Ok(()) => { - notify_tx - .send(Notification { - message: on_success.into(), - kind: NotificationKind::Info, - }) - .unwrap(); - } - Err(e) => { - notify_tx - .send(Notification { - message: format!("{e:?}"), - kind: NotificationKind::Error, - }) - .unwrap(); - } - } -} - -pub fn menu_bar( - items: Mutable>>, - notify_tx: Sender, -) -> impl Widget { - row(( - centered(label("Palette editor")), - save_button(items.clone(), notify_tx.clone()), - load_button(items.clone(), notify_tx.clone()), - export_button(items.clone(), notify_tx.clone()), - )) - .with_stretch(true) -} - -fn save_items(items: &Vec>) -> anyhow::Result { - let data = serde_json::to_string_pretty(items).context("Failed to serialize state")?; - Ok(data) -} - -type PaletteItems = Vec>; - -fn save_button(items: Mutable, notify_tx: Sender) -> impl Widget { - Button::label("Save").on_press({ - to_owned![items, notify_tx]; - move |frame, _| { - to_owned![items, notify_tx]; - let fut = async move { - let Some(file) = AsyncFileDialog::new() - .set_directory(local_dir()) - .set_file_name("colors.save.json") - .save_file() - .await - else { - return anyhow::Ok(()); - }; - - let data = save_items(&items.lock_ref())?; - - file.write(data.as_bytes()) - .await - .context("Failed to write to save file")?; - - Ok(()) - }; - - frame.spawn(notify_result(fut, notify_tx, "Saved")); - } - }) -} - -fn load_button(items: Mutable, notify_tx: Sender) -> impl Widget { - Button::label("Load").on_press({ - to_owned![items, notify_tx]; - move |frame, _| { - to_owned![items, notify_tx]; - let fut = async move { - let Some(file) = AsyncFileDialog::new() - .set_directory(local_dir()) - .pick_file() - .await - else { - return anyhow::Ok(()); - }; - - let data = file.read().await; - - let data = serde_json::from_slice(&data).context("Failed to deserialize state")?; - - items.set(data); - - Ok(()) - }; - - frame.spawn(notify_result(fut, notify_tx, "Loaded")); - } - }) -} - -fn export_button(items: Mutable, notify_tx: Sender) -> impl Widget { - Button::label("Export Json").on_press({ - to_owned![items, notify_tx]; - move |frame, _| { - let data = items - .lock_ref() - .iter() - .map(|item| { - let item = item.lock_ref(); - let tints = TINTS - .iter() - .map(|&i| { - let color = item.tint(i as f32 / 1000.0); - ( - format!("{}", i), - HexColor(Srgb::from_color(color).into_format()), - ) - }) - .collect::>(); - - (item.name.to_kebab_case(), tints) - }) - .collect::>(); - - let json = serde_json::to_string_pretty(&data).unwrap(); - - let fut = async move { - let Some(file) = AsyncFileDialog::new() - .set_directory(local_dir()) - .set_file_name("colors.json") - .save_file() - .await - else { - return anyhow::Ok(()); - }; - - file.write(json.as_bytes()) - .await - .context("Failed to write to save file")?; - - Ok(()) - }; - - frame.spawn(notify_result(fut, notify_tx.clone(), "Exported")); - } - }) -} diff --git a/violet-demo/src/palettes/mod.rs b/violet-demo/src/palettes/mod.rs deleted file mode 100644 index f0604b6..0000000 --- a/violet-demo/src/palettes/mod.rs +++ /dev/null @@ -1,392 +0,0 @@ -use editor::palette_editor; -use futures::{Stream, StreamExt}; -use glam::Vec2; -use itertools::Itertools; -use menu::menu_bar; -use ordered_float::OrderedFloat; -use serde::{Deserialize, Serialize}; -use violet::{ - core::{ - declare_atom, - layout::Align, - state::{State, StateMut, StateStream, StateStreamRef}, - style::{ - danger_element, primary_surface, success_element, warning_element, Background, SizeExt, - ValueOrRef, - }, - time::{interval, sleep}, - to_owned, - unit::Unit, - utils::{throttle, zip_latest_ref}, - widget::{ - card, col, label, row, Button, Checkbox, Rectangle, ScrollArea, Stack, StreamWidget, - Text, TextInput, WidgetExt, - }, - Edges, Scope, Widget, - }, - futures_signals::signal::Mutable, - palette::{IntoColor, Oklch, Srgb}, - web_time::Duration, -}; - -mod editor; -mod menu; - -pub struct App; - -const DEFAULT_FALLOFF: f32 = 15.0; - -impl Widget for App { - fn mount(self, scope: &mut Scope<'_>) { - let palette_item = Mutable::new( - (0..8) - .map(|i| { - Mutable::new(PaletteColor { - color: Oklch::new(0.5, 0.27, (i as f32 * 60.0) % 360.0), - falloff: DEFAULT_FALLOFF, - name: format!("Color {i}"), - }) - }) - .collect(), - ); - - let (notify_tx, notify_rx) = flume::unbounded(); - - scope.frame_mut().set_atom(self::notify_tx(), notify_tx); - - Stack::new(( - Palettes::new(palette_item), - Stack::new(Notifications { - items: notify_rx.into_stream(), - }) - .with_maximize(Vec2::ONE) - .with_horizontal_alignment(Align::End), - )) - .with_size(Unit::rel2(1.0, 1.0)) - .with_background(Background::new(primary_surface())) - .mount(scope); - } -} - -fn tints(color: impl StateStream) -> impl Widget { - puffin::profile_function!(); - row(TINTS - .iter() - .map(move |&i| { - let color = - throttle(color.stream(), || sleep(Duration::from_millis(200))).map(move |v| { - let f = (i as f32) / 1000.0; - let color = v.tint(f); - - Rectangle::new(ValueOrRef::value(color.into_color())) - .with_min_size(Unit::px2(80.0, 60.0)) - }); - - Stack::new(col(StreamWidget(color))) - .with_margin(Edges::even(4.0)) - .with_name("Tint") - }) - .collect_vec()) -} - -pub fn color_hex(color: impl IntoColor) -> String { - let hex: Srgb = color.into_color().into_format(); - format!("#{:0>2x}{:0>2x}{:0>2x}", hex.red, hex.green, hex.blue) -} - -pub struct Palettes { - items: Mutable>>, -} - -impl Palettes { - pub fn new(items: Mutable>>) -> Self { - Self { items } - } -} - -declare_atom! { - notify_tx: flume::Sender, -} - -impl Widget for Palettes { - fn mount(self, scope: &mut Scope<'_>) { - let notify_tx = scope.frame().get_atom(notify_tx()).unwrap().clone(); - - let items = self.items.clone(); - let discard = move |i| { - let items = items.clone(); - Button::new(Text::new("-")) - .on_press({ - move |_, _| { - items.lock_mut().remove(i); - } - }) - .danger() - }; - - let items = self.items.clone(); - let move_up = move |i| { - let items = items.clone(); - Button::new(Text::new("˰")).on_press({ - move |_, _| { - items.write_mut(|v| { - if i > 0 { - v.swap(i, i - 1); - } - }); - } - }) - }; - - let items = self.items.clone(); - let move_down = move |i| { - let items = items.clone(); - Button::new(Text::new("˯")).on_press({ - move |_, _| { - items.write_mut(|v| { - if i < v.len() - 1 { - v.swap(i, i + 1); - } - }); - } - }) - }; - - let current_choice = Mutable::new(Some(0)); - - let editor = zip_latest_ref( - self.items.stream(), - current_choice.stream(), - |items, i: &Option| i.and_then(|i| items.get(i).cloned()).map(palette_editor), - ); - - let palettes = StreamWidget(self.items.stream_ref({ - to_owned![current_choice]; - move |items| { - let items = items - .iter() - .enumerate() - .map({ - to_owned![current_choice]; - let discard = &discard; - let move_up = &move_up; - let move_down = &move_down; - move |(i, item)| { - puffin::profile_scope!("Update palette item", format!("{i}")); - let checkbox = Checkbox::new( - (), - current_choice - .clone() - .map(move |v| v == Some(i), move |state| state.then_some(i)), - ); - - card(row(( - checkbox, - move_down(i), - move_up(i), - discard(i), - palette_color_view(item.clone()), - ))) - } - }) - .collect_vec(); - - ScrollArea::vertical(col(items)) - } - })); - - let items = self.items.clone(); - - let new_color = Button::label("+").on_press({ - to_owned![items]; - move |_, _| { - items.write_mut(|v| { - v.push(Mutable::new(PaletteColor { - color: Oklch::new(0.5, 0.27, (v.len() as f32 * 60.0) % 360.0), - falloff: DEFAULT_FALLOFF, - name: format!("Color {}", v.len() + 1), - })); - current_choice.set(Some(v.len() - 1)); - }) - } - }); - - let sort = Button::label("Sort").on_press({ - to_owned![items]; - move |_, _| { - items.write_mut(|v| { - v.sort_by_cached_key(|v| { - let v = v.lock_ref(); - ( - (v.color.chroma / 0.37 * 5.0) as u32, - OrderedFloat(v.color.hue.into_positive_degrees()), - ) - }); - }); - } - }); - - let editor_column = col((StreamWidget(editor), palettes, card(row((new_color, sort))))); - - col(( - menu_bar(self.items.clone(), notify_tx), - row((editor_column, description())), - )) - .mount(scope) - } -} - -struct Notification { - message: String, - kind: NotificationKind, -} - -#[allow(dead_code)] -pub enum NotificationKind { - Info, - Warning, - Error, -} - -pub struct Notifications { - items: S, -} - -impl Widget for Notifications -where - S: 'static + Stream, -{ - fn mount(self, scope: &mut Scope<'_>) { - let notifications = Mutable::new(Vec::new()); - - let notifications_stream = notifications.stream_ref(|v| { - let items = v - .iter() - .map(|(_, v): &(f32, Notification)| { - let color = match v.kind { - NotificationKind::Info => success_element(), - NotificationKind::Warning => warning_element(), - NotificationKind::Error => danger_element(), - }; - card(label(v.message.clone())).with_background(Background::new(color)) - }) - .collect_vec(); - - col(items) - }); - - scope.spawn(async move { - let stream = self.items; - - let mut interval = interval(Duration::from_secs(1)).fuse(); - - let stream = stream.fuse(); - futures::pin_mut!(stream); - - loop { - futures::select! { - _ = interval.next() => { - let notifications = &mut *notifications.lock_mut(); - notifications.retain(|(time, _)| *time > 0.0); - for (time, _) in notifications { - *time -= 1.0; - } - }, - notification = stream.select_next_some() => { - notifications.lock_mut().push((5.0, notification)); - } - complete => break, - } - } - }); - - StreamWidget(notifications_stream).mount(scope); - } -} - -fn local_dir() -> std::path::PathBuf { - #[cfg(not(target_arch = "wasm32"))] - { - std::env::current_dir().unwrap() - } - #[cfg(target_arch = "wasm32")] - { - std::path::PathBuf::from(".") - } -} - -fn description() -> impl Widget { - let content = Mutable::new( - r#"Create and edit a palette of colors. Each color can be adjusted with a falloff parameter to control how quickly the color fades to white or black. - -The colors are displayed in a grid with the tints of the color. The tints are generated by adjusting the chroma and lightness of the color between 50 and 950. - -The colors can be save and loaded, and exported in a tailwind style `.json` file. - "#.to_string(), - ); - - card(TextInput::new(content)) -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct PaletteColor { - color: Oklch, - falloff: f32, - name: String, -} - -impl PaletteColor { - pub fn tint(&self, tint: f32) -> Oklch { - let chroma = self.color.chroma * (1.0 / (1.0 + self.falloff * (tint - 0.5).powi(2))); - // let color = self.base.lighten(f); - Oklch { - chroma, - l: (TINT_MAX - TINT_MIN) * (1.0 - tint) + TINT_MIN, - ..self.color - } - } -} - -fn palette_color_view(color: Mutable) -> impl Widget { - puffin::profile_function!(); - // let label = color.stream().map(|v| label(color_hex(v.color))); - let label = color.clone().map_ref(|v| &v.name, |v| &mut v.name); - - let label = TextInput::new(label); - Stack::new((row((tints(color),)), label)) - .with_vertical_alignment(Align::End) - .with_horizontal_alignment(Align::Center) -} - -pub struct HexColor(Srgb); - -impl Serialize for HexColor { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let s = format!( - "#{:0>2x}{:0>2x}{:0>2x}", - self.0.red, self.0.green, self.0.blue - ); - - serializer.serialize_str(&s) - } -} - -impl<'de> Deserialize<'de> for HexColor { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - let color: Srgb = s.trim().parse().map_err(serde::de::Error::custom)?; - Ok(HexColor(color)) - } -} - -static TINTS: &[i32] = &[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; - -/// Going from 0.0 to 1.0 is too dark to be perceptible in the higher ranges -static TINT_MIN: f32 = 0.17; -static TINT_MAX: f32 = 0.97;