use std::borrow::Cow;
use std::cell::Cell;
use std::rc::Rc;
use std::sync::LazyLock;
use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::accesskit::{Action, Role};
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
use teksilo_core::focus::FocusOrigin;
use teksilo_core::gesture::DragPhase;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{
CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{Color, CornerRadius, Orientation};
const HUE_TEXTURE_NAME_HORIZONTAL: &str = "__teksilo_color_picker_hue_h_256";
const HUE_TEXTURE_NAME_VERTICAL: &str = "__teksilo_color_picker_hue_v_256";
const HUE_TEXTURE_LENGTH: u32 = 256;
static HUE_PIXELS: LazyLock<Vec<u8>> = LazyLock::new(generate_hue_pixels);
pub(crate) struct HueStrip {
hue: Signal<f32>,
set_hue: Rc<dyn Fn(f32)>,
dragging: Rc<Cell<bool>>,
cached_bounds: Rc<Cell<Rect>>,
focus_origin: Rc<Cell<Option<FocusOrigin>>>,
orientation: Orientation,
initial_enabled: bool,
label: String,
}
impl HueStrip {
pub(crate) fn new(
hue: Signal<f32>,
set_hue: Rc<dyn Fn(f32)>,
dragging: Rc<Cell<bool>>,
) -> Self {
Self {
hue,
set_hue,
dragging,
cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
focus_origin: Rc::new(Cell::new(None)),
orientation: Orientation::Vertical,
initial_enabled: true,
label: String::new(),
}
}
pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
self.orientation = orientation;
self
}
pub(crate) fn enabled(mut self, enabled: bool) -> Self {
self.initial_enabled = enabled;
self
}
pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
}
impl std::fmt::Debug for HueStrip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HueStrip")
.field("orientation", &self.orientation)
.finish_non_exhaustive()
}
}
impl Widget for HueStrip {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
if !self.initial_enabled {
ctx.enabled_when(self_id, false);
}
let registry = ctx.binding_registry();
self.hue.bind_to(
self_id,
registry,
teksilo_core::binding::BindingLevel::RepaintOnly,
);
let cached_bounds = self.cached_bounds.clone();
let dragging = self.dragging.clone();
let set_hue = self.set_hue.clone();
let orientation = self.orientation;
let apply: Rc<dyn Fn(f32, f32)> = {
let set_hue = set_hue.clone();
Rc::new(move |x: f32, y: f32| {
let bounds = cached_bounds.get();
let t = match orientation {
Orientation::Vertical => {
if bounds.height <= 0.0 {
return;
}
(y / bounds.height).clamp(0.0, 1.0)
}
Orientation::Horizontal => {
if bounds.width <= 0.0 {
return;
}
(x / bounds.width).clamp(0.0, 1.0)
}
};
let h = (t * 360.0).min(359.999);
(set_hue)(h);
})
};
let mut handlers = HandlerSet::new()
.focusable(true)
.cursor(CursorIcon::Pointer);
{
let dragging = dragging.clone();
let apply = apply.clone();
handlers = handlers.on_drag(move |phase, _ctx| match phase {
DragPhase::Started {
position,
button: PointerButton::Primary,
} => {
dragging.set(true);
apply(position.x, position.y);
}
DragPhase::Moved { position, .. } if dragging.get() => {
apply(position.x, position.y);
}
DragPhase::Ended { .. } => {
dragging.set(false);
}
_ => {}
});
}
{
let apply = apply.clone();
handlers = handlers.on_tap(move |event, _ctx| {
apply(event.position.x, event.position.y);
});
}
{
let set_hue = set_hue.clone();
let hue = self.hue.clone();
handlers = handlers.on_key(move |event, _ctx| {
let WidgetEvent::KeyDown { key, .. } = event else {
return EventResponse::Ignored;
};
match key {
Key::ArrowUp | Key::ArrowRight => {
let next = (hue.get() + 1.0).rem_euclid(360.0);
(set_hue)(next);
EventResponse::Handled
}
Key::ArrowDown | Key::ArrowLeft => {
let next = (hue.get() - 1.0).rem_euclid(360.0);
(set_hue)(next);
EventResponse::Handled
}
Key::PageUp => {
let next = (hue.get() + 15.0).rem_euclid(360.0);
(set_hue)(next);
EventResponse::Handled
}
Key::PageDown => {
let next = (hue.get() - 15.0).rem_euclid(360.0);
(set_hue)(next);
EventResponse::Handled
}
Key::Home => {
(set_hue)(0.0);
EventResponse::Handled
}
Key::End => {
(set_hue)(359.0);
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
});
}
{
let focus_origin = self.focus_origin.clone();
handlers = handlers.on_focus(move |gained, _ctx| {
focus_origin.set(if gained {
Some(FocusOrigin::Keyboard)
} else {
None
});
});
}
{
let set_hue = set_hue.clone();
let hue = self.hue.clone();
handlers = handlers.on_access_action(move |action, _ctx| match action {
Action::Increment => {
(set_hue)((hue.get() + 1.0).rem_euclid(360.0));
EventResponse::Handled
}
Action::Decrement => {
(set_hue)((hue.get() - 1.0).rem_euclid(360.0));
EventResponse::Handled
}
_ => EventResponse::Ignored,
});
}
ctx.apply_self_handlers(handlers);
Vec::new()
}
fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
use crate::styles::recipe_color_picker_style as cp;
let size = match self.orientation {
Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
};
size.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
_children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
self.cached_bounds.set(bounds);
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
use crate::styles::recipe_color_picker_style as cp;
self.cached_bounds.set(bounds);
let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
let (texture_name, tex_w, tex_h) = match self.orientation {
Orientation::Horizontal => (HUE_TEXTURE_NAME_HORIZONTAL, HUE_TEXTURE_LENGTH, 1),
Orientation::Vertical => (HUE_TEXTURE_NAME_VERTICAL, 1, HUE_TEXTURE_LENGTH),
};
canvas.ensure_image_registered(
texture_name,
tex_w,
tex_h,
Cow::Borrowed(HUE_PIXELS.as_slice()),
);
canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
canvas.draw_image(bounds, texture_name);
canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
let thumb_w = cp::STRIP_THUMB_WIDTH;
let thumb_h = cp::STRIP_THUMB_HEIGHT;
let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
let (cx, cy) = match self.orientation {
Orientation::Vertical => (bounds.x + bounds.width * 0.5, bounds.y + bounds.height * t),
Orientation::Horizontal => {
(bounds.x + bounds.width * t, bounds.y + bounds.height * 0.5)
}
};
let thumb_rect = match self.orientation {
Orientation::Vertical => Rect::new(
bounds.x - 2.0,
cy - thumb_h * 0.5,
bounds.width + 4.0,
thumb_h,
),
Orientation::Horizontal => Rect::new(
cx - thumb_w * 0.5,
bounds.y - 2.0,
thumb_w,
bounds.height + 4.0,
),
};
canvas.fill_rounded_rect(thumb_rect, thumb_radius, Color::WHITE);
canvas.stroke_rounded_rect(
thumb_rect,
thumb_radius,
Color::new(0.0, 0.0, 0.0, 0.5),
1.0,
);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(Role::Slider);
if !self.label.is_empty() {
builder.set_name(&self.label);
}
builder.set_numeric_value(self.hue.get() as f64);
builder.set_min_numeric_value(0.0);
builder.set_max_numeric_value(360.0);
builder.set_numeric_value_step(1.0);
builder.set_numeric_value_jump(15.0);
let orientation = match self.orientation {
Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
};
builder.set_orientation(orientation);
builder.add_action(Action::Increment);
builder.add_action(Action::Decrement);
builder.add_action(Action::Focus);
}
}
fn generate_hue_pixels() -> Vec<u8> {
let mut pixels = Vec::with_capacity(HUE_TEXTURE_LENGTH as usize * 4);
for i in 0..HUE_TEXTURE_LENGTH {
let h = (i as f32 / HUE_TEXTURE_LENGTH as f32) * 360.0;
let c = Color::from_hsv(h, 1.0, 1.0);
pixels.push((c.r() * 255.0) as u8);
pixels.push((c.g() * 255.0) as u8);
pixels.push((c.b() * 255.0) as u8);
pixels.push(255);
}
pixels
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hue_pixels_are_rainbow() {
let pixels = generate_hue_pixels();
assert_eq!(pixels.len(), HUE_TEXTURE_LENGTH as usize * 4);
assert_eq!(pixels[0], 255);
assert_eq!(pixels[1], 0);
assert_eq!(pixels[2], 0);
let mid = (HUE_TEXTURE_LENGTH as usize / 2) * 4;
assert!(pixels[mid] < 50);
assert!(pixels[mid + 1] > 200);
assert!(pixels[mid + 2] > 200);
}
}