1use std::cell::Cell;
32use std::rc::Rc;
33
34use teksilo_canvas::paint::GradientStop;
35use teksilo_canvas::{Canvas, Paint, Point, Rect, Size, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::accesskit::Role;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
40use teksilo_core::gesture::DragPhase;
41use teksilo_core::pointer::touch_action::TouchAction;
42use teksilo_core::signal::Signal;
43use teksilo_core::widget::{
44 CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
45};
46use teksilo_core::widget_builder::HandlerSet;
47use teksilo_core::widget_id::WidgetId;
48use teksilo_i18n::{LocalizedString, lit};
49use teksilo_tokens::{Color, CornerRadius};
50
51#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58enum CanvasStep {
59 SaturationUp,
60 SaturationDown,
61 BrightnessUp,
62 BrightnessDown,
63}
64
65const FINE_STEP: f32 = 0.01;
67
68const COARSE_STEP: f32 = 0.10;
70
71impl CanvasStep {
72 const ALL: [CanvasStep; 4] = [
73 Self::SaturationDown,
74 Self::SaturationUp,
75 Self::BrightnessDown,
76 Self::BrightnessUp,
77 ];
78
79 fn from_key(key: Key) -> Option<Self> {
86 match key {
87 Key::ArrowRight => Some(Self::SaturationUp),
88 Key::ArrowLeft => Some(Self::SaturationDown),
89 Key::ArrowUp => Some(Self::BrightnessUp),
90 Key::ArrowDown => Some(Self::BrightnessDown),
91 _ => None,
92 }
93 }
94
95 fn label(self) -> LocalizedString {
96 match self {
97 Self::SaturationUp => lit!("Increase saturation"),
98 Self::SaturationDown => lit!("Decrease saturation"),
99 Self::BrightnessUp => lit!("Increase brightness"),
100 Self::BrightnessDown => lit!("Decrease brightness"),
101 }
102 }
103}
104
105pub(crate) struct HsvCanvas {
106 hue: Signal<f32>,
107 saturation: Signal<f32>,
108 value_hsv: Signal<f32>,
109 set_hsv: Rc<dyn Fn(f32, f32, f32)>,
110 dragging: Rc<Cell<bool>>,
111 cached_bounds: Rc<Cell<Rect>>,
112 initial_enabled: bool,
114}
115
116impl HsvCanvas {
117 pub(crate) fn new(
118 hue: Signal<f32>,
119 saturation: Signal<f32>,
120 value_hsv: Signal<f32>,
121 set_hsv: Rc<dyn Fn(f32, f32, f32)>,
122 dragging: Rc<Cell<bool>>,
123 ) -> Self {
124 Self {
125 hue,
126 saturation,
127 value_hsv,
128 set_hsv,
129 dragging,
130 cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
131 initial_enabled: true,
132 }
133 }
134
135 pub(crate) fn enabled(mut self, enabled: bool) -> Self {
137 self.initial_enabled = enabled;
138 self
139 }
140}
141
142impl std::fmt::Debug for HsvCanvas {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("HsvCanvas").finish_non_exhaustive()
145 }
146}
147
148impl Widget for HsvCanvas {
149 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
150 let self_id = ctx.self_id();
151 if !self.initial_enabled {
153 ctx.enabled_when(self_id, false);
154 }
155 let registry = ctx.binding_registry();
156 self.hue.bind_to(
159 self_id,
160 registry,
161 teksilo_core::binding::BindingLevel::RepaintOnly,
162 );
163 self.saturation.bind_to(
164 self_id,
165 registry,
166 teksilo_core::binding::BindingLevel::RepaintOnly,
167 );
168 self.value_hsv.bind_to(
169 self_id,
170 registry,
171 teksilo_core::binding::BindingLevel::RepaintOnly,
172 );
173
174 let cached_bounds = self.cached_bounds.clone();
177 let dragging = self.dragging.clone();
178 let set_hsv = self.set_hsv.clone();
179 let hue_for_drag = self.hue.clone();
180
181 let apply: Rc<dyn Fn(f32, f32)> = {
182 let set_hsv = set_hsv.clone();
183 Rc::new(move |x: f32, y: f32| {
184 let bounds = cached_bounds.get();
185 if bounds.width <= 0.0 || bounds.height <= 0.0 {
186 return;
187 }
188 let sat = (x / bounds.width).clamp(0.0, 1.0);
191 let val = (1.0 - y / bounds.height).clamp(0.0, 1.0);
193 (set_hsv)(hue_for_drag.get(), sat, val);
194 })
195 };
196
197 let mut handlers = HandlerSet::new()
198 .focusable(true)
203 .cursor(CursorIcon::Crosshair)
204 .touch_action(TouchAction::NONE);
213
214 let step_hsv: Rc<dyn Fn(CanvasStep, bool, &mut teksilo_core::widget::EventContext)> = {
221 let set_hsv = set_hsv.clone();
222 let hue = self.hue.clone();
223 let saturation = self.saturation.clone();
224 let value_hsv = self.value_hsv.clone();
225 Rc::new(move |step, coarse, ctx| {
226 let delta = if coarse { COARSE_STEP } else { FINE_STEP };
227 let (mut sat, mut val) = (
228 saturation.get().clamp(0.0, 1.0),
229 value_hsv.get().clamp(0.0, 1.0),
230 );
231 match step {
232 CanvasStep::SaturationUp => sat = (sat + delta).min(1.0),
233 CanvasStep::SaturationDown => sat = (sat - delta).max(0.0),
234 CanvasStep::BrightnessUp => val = (val + delta).min(1.0),
235 CanvasStep::BrightnessDown => val = (val - delta).max(0.0),
236 }
237 (set_hsv)(hue.get(), sat, val);
238 ctx.announce(format!(
239 "Saturation {}%, brightness {}%",
240 (sat * 100.0).round() as i32,
241 (val * 100.0).round() as i32
242 ));
243 })
244 };
245
246 {
247 let step_hsv = step_hsv.clone();
248 handlers = handlers.on_key(move |event, ctx| {
249 if let WidgetEvent::KeyDown { key, modifiers, .. } = event
250 && let Some(step) = CanvasStep::from_key(*key)
251 {
252 step_hsv(step, modifiers.shift(), ctx);
253 return EventResponse::Handled;
254 }
255 EventResponse::Ignored
256 });
257 }
258 for step in CanvasStep::ALL {
259 let step_hsv = step_hsv.clone();
260 handlers =
261 handlers.access_custom_action(step.label(), move |ctx| step_hsv(step, false, ctx));
262 }
263
264 {
265 let dragging = dragging.clone();
266 let apply = apply.clone();
267 handlers = handlers.on_drag(move |phase, _ctx| match phase {
268 DragPhase::Started {
269 position,
270 button: PointerButton::Primary,
271 ..
272 } => {
273 dragging.set(true);
274 apply(position.x, position.y);
275 }
276 DragPhase::Moved { position, .. } if dragging.get() => {
277 apply(position.x, position.y);
278 }
279 DragPhase::Ended { .. } => {
280 dragging.set(false);
281 }
282 _ => {}
283 });
284 }
285 {
286 let apply = apply.clone();
287 handlers = handlers.on_tap(move |event, _ctx| {
288 apply(event.position.x, event.position.y);
289 });
290 }
291
292 ctx.apply_self_handlers(handlers);
293 Vec::new()
294 }
295
296 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
297 use crate::styles::recipe_color_picker_style as cp;
298 Size::new(cp::CANVAS_WIDTH, cp::CANVAS_HEIGHT).into()
299 }
300
301 fn place_children(
302 &self,
303 bounds: Rect,
304 _proposal: SizeProposal,
305 _children: &mut [WidgetPlacement],
306 _ctx: &LayoutContext,
307 ) {
308 self.cached_bounds.set(bounds);
309 }
310
311 fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
312 use crate::styles::recipe_color_picker_style as cp;
313 self.cached_bounds.set(bounds);
314 let radius = CornerRadius::uniform(cp::CANVAS_CORNER_RADIUS);
315
316 let hue = self.hue.get();
318 let base = Color::from_hsv(hue, 1.0, 1.0);
319 canvas.fill_rounded_rect(bounds, radius, base);
320
321 canvas.fill_rounded_rect(
326 bounds,
327 radius,
328 Paint::LinearGradient {
329 start: Point::new(0.0, 0.0),
330 end: Point::new(bounds.width, 0.0),
331 stops: vec![
332 GradientStop {
333 offset: 0.0,
334 color: Color::WHITE,
335 },
336 GradientStop {
337 offset: 1.0,
338 color: Color::WHITE.with_alpha(0.0),
339 },
340 ],
341 },
342 );
343
344 canvas.fill_rounded_rect(
346 bounds,
347 radius,
348 Paint::LinearGradient {
349 start: Point::new(0.0, 0.0),
350 end: Point::new(0.0, bounds.height),
351 stops: vec![
352 GradientStop {
353 offset: 0.0,
354 color: Color::BLACK.with_alpha(0.0),
355 },
356 GradientStop {
357 offset: 1.0,
358 color: Color::BLACK,
359 },
360 ],
361 },
362 );
363
364 let sat = self.saturation.get().clamp(0.0, 1.0);
366 let val = self.value_hsv.get().clamp(0.0, 1.0);
367 let cx = bounds.x + bounds.width * sat;
368 let cy = bounds.y + bounds.height * (1.0 - val);
369 let center = Point::new(cx, cy);
370
371 canvas.stroke_circle(
372 center,
373 cp::INDICATOR_RADIUS + cp::INDICATOR_INNER_STROKE_WIDTH,
374 cp::INDICATOR_OUTER_COLOR,
375 cp::INDICATOR_OUTER_STROKE_WIDTH,
376 );
377 canvas.stroke_circle(
378 center,
379 cp::INDICATOR_RADIUS,
380 cp::INDICATOR_INNER_COLOR,
381 cp::INDICATOR_INNER_STROKE_WIDTH,
382 );
383 }
384
385 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
386 builder.set_role(Role::Group);
395 builder.set_name(lit!("Saturation and brightness").resolve_now());
396 builder.set_value(format!(
397 "Saturation {}%, brightness {}%",
398 (self.saturation.get().clamp(0.0, 1.0) * 100.0).round() as i32,
399 (self.value_hsv.get().clamp(0.0, 1.0) * 100.0).round() as i32
400 ));
401 }
402}