use crate::prelude::*;
use crate::{IntoText, TextSource};
use day_core::{BuildCx, Flex, Piece, RNode, with_tree};
use day_reactive::{Binding, bind_seeded, watch};
use day_spec::{Event, Support};
pub use day_spec::controls::colorpicker::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PickerIdiom {
#[default]
Automatic,
Native,
Composed,
}
pub fn support() -> day_spec::Support {
let support = day_core::capability(day_spec::Cap::ColorPicker);
if support == day_spec::Support::Unsupported {
day_spec::Support::Emulated
} else {
support
}
}
fn default_presets() -> Vec<Color> {
let mut v: Vec<Color> = (0..12)
.map(|i| Color::hsv(i as f64 * 30.0, 0.78, 0.92))
.collect();
v.push(Color::WHITE);
v.push(Color::rgb(0.6, 0.6, 0.62));
v.push(Color::BLACK);
v
}
pub struct ColorPicker<C: Binding<Color>> {
color: C,
alpha: bool,
idiom: PickerIdiom,
title: Option<TextSource>,
presets: Option<Vec<Color>>,
key: day_core::ElementId,
}
pub fn color_picker<C: Binding<Color>>(color: C) -> ColorPicker<C> {
ColorPicker {
color,
alpha: false,
idiom: PickerIdiom::Automatic,
title: None,
presets: None,
key: day_core::id!(color_picker),
}
}
#[crate::builder(ColorPickerBuilder)]
impl<C: Binding<Color>> ColorPicker<C> {
pub fn alpha(mut self) -> Self {
self.alpha = true;
self
}
pub fn idiom(mut self, idiom: PickerIdiom) -> Self {
self.idiom = idiom;
self
}
pub fn native(self) -> Self {
self.idiom(PickerIdiom::Native)
}
pub fn composed(self) -> Self {
self.idiom(PickerIdiom::Composed)
}
pub fn title<M>(mut self, t: impl IntoText<M>) -> Self {
self.title = Some(t.into_text());
self
}
pub fn presets(mut self, presets: Vec<Color>) -> Self {
self.presets = Some(presets);
self
}
pub fn id(mut self, id: day_core::ElementId) -> Self {
self.key = id;
self
}
}
impl<C: Binding<Color>> Piece for ColorPicker<C> {
fn build(self, cx: &mut BuildCx) -> RNode {
let ColorPicker {
color,
alpha,
idiom,
title,
presets,
key,
} = self;
let title = title.map(|t| t.initial()).unwrap_or_default();
let native = match idiom {
PickerIdiom::Composed => false,
PickerIdiom::Native => true,
PickerIdiom::Automatic => support() == Support::Native,
};
if native {
build_native(cx, color, alpha, title, key)
} else {
composed_well(
color,
alpha,
title,
presets.unwrap_or_else(default_presets),
key,
)
.build(cx)
}
}
}
fn build_native<C: Binding<Color>>(
cx: &mut BuildCx,
color: C,
alpha: bool,
title: String,
key: day_core::ElementId,
) -> RNode {
let initial = color.peek();
let node = cx.leaf(
KIND,
&ColorProps {
color: initial,
alpha,
title,
},
Flex::default(),
);
with_tree(|t| t.set_id(node, key.as_str().to_owned()));
let c2 = color.clone();
bind_seeded(
initial,
move || c2.read(),
move |c: &Color| {
with_tree(|t| t.patch(node, Box::new(ColorPatch::SetColor(*c)), false));
},
);
cx.on(node, move |ev| {
let picked = match ev {
Event::Custom { text, .. } => Color::parse(text),
Event::TextChanged(s) => Color::parse(s),
_ => None,
};
if let Some(c) = picked {
color.write(if alpha { c } else { c.with_alpha(1.0) });
}
});
node
}
const FIELD_W: f64 = 264.0;
const FIELD_H: f64 = 160.0;
const FIELD_R: f64 = 10.0;
const STRIP_H: f64 = 20.0;
const SWATCH: f64 = 26.0;
const CARD_PAD: f64 = 18.0;
const CARD_W: f64 = FIELD_W + CARD_PAD * 2.0;
const PANEL_SURFACE: Color = Color::rgb(0.13, 0.14, 0.17);
const PANEL_TEXT: Color = Color::rgb(0.93, 0.94, 0.96);
fn well_width(alpha: bool) -> f64 {
if alpha { 96.0 } else { 72.0 }
}
const WELL_H: f64 = 26.0;
fn composed_well<C: Binding<Color>>(
color: C,
alpha: bool,
title: String,
presets: Vec<Color>,
key: day_core::ElementId,
) -> AnyPiece {
let open: Signal<Option<String>> = Signal::new(None);
let open_key = key.as_str().to_owned();
let cw = color.clone();
let well = canvas(move |d, size| {
let c = cw.read();
let r = Rect::new(0.0, 0.0, size.width, size.height);
if c.a < 1.0 {
checkerboard(d, size);
}
d.fill(Shape::RoundedRect(r, 7.0), c);
d.stroke(
Shape::RoundedRect(r.inset(0.5), 7.0),
Color::BLACK.with_alpha(0.25),
1.0,
);
d.text(
&c.to_hex_string(),
Point::new(size.width / 2.0, size.height / 2.0),
TextStyle {
size: 12.0,
color: day_spec::props::ButtonStyleSpec::on_tint(c),
anchor: TextAnchor::CENTERED,
..Default::default()
},
);
})
.on_tap(move || open.set(Some(open_key.clone())))
.a11y(|a| {
a.role(Role::Button)
.label(day_localization::t(day_localization::key::day_color))
})
.id(key.clone())
.frame(well_width(alpha), WELL_H);
zstack((
well,
cover(open, move |_| {
panel(color.clone(), alpha, title.clone(), presets.clone(), open)
})
.background(|_: &String| Color::rgba(0.06, 0.07, 0.09, 0.92))
.unrouted(),
))
.any()
}
fn panel<C: Binding<Color>>(
color: C,
alpha: bool,
title: String,
presets: Vec<Color>,
open: Signal<Option<String>>,
) -> AnyPiece {
let entry = color.peek();
let (h0, s0, v0) = entry.to_hsv();
let hue = Signal::new(h0);
let sat = Signal::new(s0);
let val = Signal::new(v0);
let opacity = Signal::new(entry.a);
watch(
move || {
Color::hsva(
hue.get(),
sat.get(),
val.get(),
if alpha { opacity.get() } else { 1.0 },
)
},
{
let color = color.clone();
move |c: &Color, _| color.write(*c)
},
);
let current = move || {
Color::hsva(
hue.get(),
sat.get(),
val.get(),
if alpha { opacity.get() } else { 1.0 },
)
};
let close = move || open.set(None);
let cancel = move || {
color.write(entry);
open.set(None);
};
let mut rows: Vec<AnyPiece> = Vec::new();
if !title.is_empty() {
rows.push(label(title).font(Font::Headline).color(PANEL_TEXT).any());
}
rows.push(shade_field(hue, sat, val));
rows.push(hue_strip(hue));
if alpha {
rows.push(opacity_strip(current, opacity));
}
rows.push(readout(current));
if !presets.is_empty() {
rows.push(preset_row(presets, hue, sat, val, opacity, alpha));
}
rows.push(
row((
button(day_localization::t(day_localization::key::day_cancel))
.action(cancel)
.id(day_core::id!(color_picker_cancel)),
spacer(),
button(day_localization::t(day_localization::key::day_done))
.action(close)
.id(day_core::id!(color_picker_done)),
))
.grow_w()
.any(),
);
let card = column(rows)
.spacing(12.0)
.align(HAlign::Leading)
.padding(CARD_PAD)
.background(PANEL_SURFACE)
.corner_radius(16.0)
.width(CARD_W)
.id(day_core::id!(color_picker_panel));
column((spacer(), card, spacer()))
.align(HAlign::Center)
.grow()
.any()
}
fn shade_field(hue: Signal<f64>, sat: Signal<f64>, val: Signal<f64>) -> AnyPiece {
let pick = move |p: Point| {
sat.set((p.x / FIELD_W).clamp(0.0, 1.0));
val.set(1.0 - (p.y / FIELD_H).clamp(0.0, 1.0));
};
canvas(move |d, size| {
let r = Rect::new(0.0, 0.0, size.width, size.height);
d.clipped(Shape::RoundedRect(r, FIELD_R), |d| {
d.fill(Shape::Rect(r), Color::hsv(hue.get(), 1.0, 1.0));
d.fill(
Shape::Rect(r),
LinearGradient::horizontal(Color::WHITE, Color::WHITE.with_alpha(0.0)),
);
d.fill(
Shape::Rect(r),
LinearGradient::vertical(Color::BLACK.with_alpha(0.0), Color::BLACK),
);
marker(
d,
Point::new(sat.get() * size.width, (1.0 - val.get()) * size.height),
7.0,
);
});
})
.on_drag(move |drag| pick(drag.location))
.on_tap_at(pick)
.a11y(|a| a.label(day_localization::t(day_localization::key::day_color_shade)))
.id(day_core::id!(color_picker_shade))
.frame(FIELD_W, FIELD_H)
.any()
}
fn hue_strip(hue: Signal<f64>) -> AnyPiece {
let pick = move |p: Point| hue.set((p.x / FIELD_W).clamp(0.0, 1.0) * 360.0);
canvas(move |d, size| {
let r = Rect::new(0.0, 0.0, size.width, size.height);
let stops: Vec<(f64, Color)> = (0..=6)
.map(|i| (i as f64 / 6.0, Color::hsv(i as f64 * 60.0, 1.0, 1.0)))
.collect();
d.fill(
Shape::RoundedRect(r, size.height / 2.0),
LinearGradient::new(UnitPoint::LEADING, UnitPoint::TRAILING, stops),
);
slider_thumb(d, hue.get() / 360.0, size);
})
.on_drag(move |drag| pick(drag.location))
.on_tap_at(pick)
.a11y(|a| a.label(day_localization::t(day_localization::key::day_color_hue)))
.id(day_core::id!(color_picker_hue))
.frame(FIELD_W, STRIP_H)
.any()
}
fn opacity_strip(current: impl Fn() -> Color + 'static, opacity: Signal<f64>) -> AnyPiece {
let pick = move |p: Point| opacity.set((p.x / FIELD_W).clamp(0.0, 1.0));
canvas(move |d, size| {
let r = Rect::new(0.0, 0.0, size.width, size.height);
d.clipped(Shape::RoundedRect(r, size.height / 2.0), |d| {
checkerboard(d, size)
});
let opaque = current().with_alpha(1.0);
d.fill(
Shape::RoundedRect(r, size.height / 2.0),
LinearGradient::horizontal(opaque.with_alpha(0.0), opaque),
);
slider_thumb(d, opacity.get(), size);
})
.on_drag(move |drag| pick(drag.location))
.on_tap_at(pick)
.a11y(|a| {
a.label(day_localization::t(
day_localization::key::day_color_opacity,
))
})
.id(day_core::id!(color_picker_opacity))
.frame(FIELD_W, STRIP_H)
.any()
}
fn preset_row(
presets: Vec<Color>,
hue: Signal<f64>,
sat: Signal<f64>,
val: Signal<f64>,
opacity: Signal<f64>,
alpha: bool,
) -> AnyPiece {
let swatches: Vec<AnyPiece> = presets
.into_iter()
.map(|c| {
canvas(move |d, size| {
let r = Rect::new(0.0, 0.0, size.width, size.height);
if c.a < 1.0 {
checkerboard(d, size);
}
d.fill(Shape::RoundedRect(r, 6.0), c);
d.stroke(
Shape::RoundedRect(r.inset(0.5), 6.0),
Color::WHITE.with_alpha(0.22),
1.0,
);
})
.on_tap(move || {
let (h, s, v) = c.to_hsv();
hue.set(h);
sat.set(s);
val.set(v);
if alpha {
opacity.set(c.a);
}
})
.frame(SWATCH, SWATCH)
.any()
})
.collect();
row(swatches)
.spacing(6.0)
.fit(RowFit::Wrap { run_spacing: 6.0 })
.grow_w()
.id(day_core::id!(color_picker_presets))
.any()
}
fn readout(current: impl Fn() -> Color + Copy + 'static) -> AnyPiece {
row((
canvas(move |d, size| {
checkerboard(d, size);
d.fill(
Shape::RoundedRect(Rect::new(0.0, 0.0, size.width, size.height), 7.0),
current(),
);
})
.frame(44.0, 28.0),
label(move || current().to_hex_string())
.monospace()
.color(PANEL_TEXT)
.id(day_core::id!(color_picker_value)),
))
.spacing(10.0)
.align(VAlign::Center)
.any()
}
fn marker(d: &mut Draw, at: Point, radius: f64) {
let ring = |r: f64| Shape::Ellipse(Rect::new(at.x - r, at.y - r, r * 2.0, r * 2.0));
d.stroke(ring(radius + 1.0), Color::BLACK.with_alpha(0.45), 3.0);
d.stroke(ring(radius), Color::WHITE, 2.0);
}
fn slider_thumb(d: &mut Draw, t: f64, size: Size) {
const W: f64 = 8.0;
let half = W / 2.0;
let (lo, hi) = if size.width >= W {
(half, size.width - half)
} else {
(size.width / 2.0, size.width / 2.0)
};
let x = (t.clamp(0.0, 1.0) * size.width).clamp(lo, hi);
let r = Rect::new(x - W / 2.0, 0.0, W, size.height);
d.fill(Shape::RoundedRect(r, W / 2.0), Color::WHITE);
d.stroke(
Shape::RoundedRect(r.inset(0.5), W / 2.0 - 0.5),
Color::BLACK.with_alpha(0.4),
1.0,
);
}
fn checkerboard(d: &mut Draw, size: Size) {
const CELL: f64 = 6.0;
d.fill(
Shape::Rect(Rect::new(0.0, 0.0, size.width, size.height)),
Color::rgb(0.86, 0.86, 0.88),
);
let cols = (size.width / CELL).ceil() as i64;
let rows = (size.height / CELL).ceil() as i64;
for row_i in 0..rows {
for col in 0..cols {
if (row_i + col) % 2 == 0 {
continue;
}
let x = col as f64 * CELL;
let y = row_i as f64 * CELL;
d.fill(
Shape::Rect(Rect::new(
x,
y,
CELL.min(size.width - x),
CELL.min(size.height - y),
)),
Color::rgb(0.66, 0.66, 0.69),
);
}
}
}
#[cfg(feature = "conformance")]
pub mod conformance {
use crate::*;
use day_core::conformance::{Case, Drive};
use day_reactive::Signal;
use day_spec::Color;
use super::{KIND, color_picker};
fn hex(c: Color) -> String {
let ch = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", ch(c.r), ch(c.g), ch(c.b))
}
#[day_macros::test(day_core)]
fn colorpicker_composed_picks() -> Case {
Case::new()
.proves(KIND)
.page(|| {
let tint = Signal::new(Color::rgb(232.0 / 255.0, 106.0 / 255.0, 60.0 / 255.0));
column((
label(move || format!("tint {}", hex(tint.get())))
.id(day_core::id!(colorpicker::tint_value)),
color_picker(tint)
.composed()
.id(day_core::id!(colorpicker::tint)),
))
})
.drive(|d: Drive| async move {
d.tap("colorpicker::tint").await?;
d.wait_idle().await?;
d.assert_visible("color_picker_panel").await?;
d.assert_text("color_picker_value", "#e86a3c").await?;
d.tap_at("color_picker_shade", 0.0, 0.0).await?;
d.assert_text("color_picker_value", "#ffffff").await?;
d.tap("color_picker_done").await?;
d.wait_idle().await?;
d.assert_text("colorpicker::tint_value", "tint #ffffff")
.await
})
}
}