teksilo_widgets/color_picker/
swatch.rs1use std::cell::Cell;
30use std::rc::Rc;
31
32use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::accesskit::{Action, Role};
35use teksilo_core::build_context::BuildContext;
36use teksilo_core::event::{EventResponse, Key, WidgetEvent};
37use teksilo_core::focus::FocusOrigin;
38use teksilo_core::styles::FocusRingStroke;
39use teksilo_core::widget::{
40 CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
41};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_i18n::{LocalizedString, resolve_message_widget};
45use teksilo_tokens::{Color, CornerRadius};
46
47use super::alpha_strip::paint_checkerboard;
48
49type ActivateFn = Rc<dyn Fn(&mut EventContext)>;
50
51pub struct ColorSwatch {
59 color: teksilo_core::signal::Prop<Color>,
60 selected: bool,
61 label: Option<LocalizedString>,
62 size: Option<f32>,
63 corner_radius: Option<f32>,
64 enabled: teksilo_core::signal::Prop<bool>,
67 on_activate: Option<ActivateFn>,
68 focus_origin: Rc<Cell<Option<FocusOrigin>>>,
69 tooltip_text: Option<LocalizedString>,
73 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
75 composite_tooltip_content: Option<Box<dyn Widget>>,
77}
78
79impl ColorSwatch {
80 pub fn new(color: impl Into<teksilo_core::signal::Prop<Color>>) -> Self {
84 Self {
85 color: color.into(),
86 selected: false,
87 label: None,
88 size: None,
89 corner_radius: None,
90 enabled: teksilo_core::signal::Prop::Static(true),
91 on_activate: None,
92 focus_origin: Rc::new(Cell::new(None)),
93 tooltip_text: None,
94 rich_tooltip_source: None,
95 composite_tooltip_content: None,
96 }
97 }
98
99 pub fn selected(mut self, selected: bool) -> Self {
102 self.selected = selected;
103 self
104 }
105
106 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
109 self.label = Some(label.into());
110 self
111 }
112
113 pub fn size(mut self, size: f32) -> Self {
116 self.size = Some(size.max(0.0));
117 self
118 }
119
120 pub fn corner_radius(mut self, r: f32) -> Self {
123 self.corner_radius = Some(r.max(0.0));
124 self
125 }
126
127 pub fn enabled(mut self, enabled: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
130 self.enabled = enabled.into();
131 self
132 }
133
134 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
137 self.on_activate = Some(Rc::new(f));
138 self
139 }
140
141 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
146 self.tooltip_text = Some(text.into());
147 self.rich_tooltip_source = None;
148 self.composite_tooltip_content = None;
149 self
150 }
151
152 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
157 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
158 self.tooltip_text = None;
159 self.composite_tooltip_content = None;
160 self
161 }
162
163 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
168 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
169 self.tooltip_text = None;
170 self.composite_tooltip_content = None;
171 self
172 }
173
174 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
179 self.composite_tooltip_content = Some(Box::new(content));
180 self.tooltip_text = None;
181 self.rich_tooltip_source = None;
182 self
183 }
184}
185
186impl std::fmt::Debug for ColorSwatch {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("ColorSwatch")
189 .field("color", &self.color.get())
190 .field("selected", &self.selected)
191 .field("enabled", &self.enabled.get())
192 .finish_non_exhaustive()
193 }
194}
195
196impl Widget for ColorSwatch {
197 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
198 let self_id = ctx.self_id();
199 ctx.enabled_when(self_id, self.enabled.clone());
201 let on_activate = self.on_activate.clone();
202 let mut handlers = HandlerSet::new()
205 .focusable(true)
206 .cursor(CursorIcon::Pointer);
207
208 if let Some(cb) = on_activate.clone() {
209 handlers = handlers.on_tap(move |_pos, ctx_evt| {
210 cb(ctx_evt);
211 });
212 }
213 if let Some(cb) = on_activate.clone() {
214 handlers = handlers.on_key(move |event, ctx_evt| {
215 let WidgetEvent::KeyDown { key, .. } = event else {
216 return EventResponse::Ignored;
217 };
218 match key {
219 Key::Enter | Key::Space => {
220 cb(ctx_evt);
221 EventResponse::Handled
222 }
223 _ => EventResponse::Ignored,
224 }
225 });
226 }
227 if let Some(cb) = on_activate {
228 handlers = handlers.on_access_action(move |action, ctx_evt| match action {
229 Action::Click => {
230 cb(ctx_evt);
231 EventResponse::Handled
232 }
233 _ => EventResponse::Ignored,
234 });
235 }
236
237 {
238 let focus_origin = self.focus_origin.clone();
239 handlers = handlers.on_focus(move |gained, _ctx| {
240 focus_origin.set(if gained {
241 Some(FocusOrigin::Keyboard)
242 } else {
243 None
244 });
245 });
246 }
247
248 ctx.apply_self_handlers(handlers);
249
250 if let Some(content) = self.composite_tooltip_content.take() {
252 let delay = ctx.theme().motion.tooltip_delay_heavy;
253 crate::tooltip::attach_composite_tooltip_boxed(ctx, self_id, content, delay);
254 } else if let Some(source) = self.rich_tooltip_source.clone() {
255 let delay = ctx.theme().motion.tooltip_delay;
256 crate::tooltip::attach_rich_tooltip_source(ctx, self_id, source, delay);
257 } else if let Some(text) = self.tooltip_text.clone() {
258 let delay = ctx.theme().motion.tooltip_delay;
259 crate::tooltip::attach_plain_tooltip(ctx, self_id, text, delay);
260 }
261
262 let self_id = ctx.self_id();
266 let registry = ctx.binding_registry();
267 self.color.register_if_bound(
268 self_id,
269 registry,
270 teksilo_core::binding::BindingLevel::AccessibilityOnly,
271 );
272 self.color.register_if_bound(
273 self_id,
274 registry,
275 teksilo_core::binding::BindingLevel::RepaintOnly,
276 );
277
278 Vec::new()
279 }
280
281 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
282 use crate::styles::recipe_color_picker_style as cp;
283 let size = self.size.unwrap_or(cp::SWATCH_SIZE);
284 Size::new(size, size).into()
285 }
286
287 fn hit_outset(
298 &self,
299 kind: teksilo_tokens::PointerKind,
300 tokens: &teksilo_tokens::InputTokens,
301 ) -> teksilo_canvas::EdgeInsets {
302 use crate::styles::recipe_color_picker_style as cp;
303 if self.on_activate.is_none() || !self.enabled.get() {
304 return teksilo_canvas::EdgeInsets::ZERO;
305 }
306 let size = self.size.unwrap_or(cp::SWATCH_SIZE);
307 crate::button::target_outset(Size::new(size, size), kind, tokens)
308 }
309
310 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
311 use crate::styles::recipe_color_picker_style as cp;
312 let radius = CornerRadius::uniform(self.corner_radius.unwrap_or(cp::SWATCH_CORNER_RADIUS));
313 let color = self.color.get();
314
315 if color.a() < 1.0 {
317 paint_checkerboard(
318 canvas,
319 bounds,
320 cp::CHECKER_CELL,
321 cp::CHECKER_COLOR_A,
322 cp::CHECKER_COLOR_B,
323 );
324 }
325
326 canvas.fill_rounded_rect(bounds, radius, color);
327
328 if self.selected {
330 canvas.stroke_rounded_rect(
331 bounds,
332 radius,
333 ctx.theme.colors.accent,
334 cp::SWATCH_SELECTED_STROKE_WIDTH,
335 );
336 } else {
337 canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
340 }
341
342 if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
344 let ring = FocusRingStroke::around(
345 bounds,
346 self.corner_radius.unwrap_or(cp::SWATCH_CORNER_RADIUS),
347 &ctx.theme.shape,
348 );
349 canvas.stroke_rounded_rect(
350 ring.rect,
351 CornerRadius::uniform(ring.corner_radius),
352 ctx.theme.colors.focus_ring,
353 ctx.theme.shape.focus_ring_width,
354 );
355 }
356 }
357
358 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
359 builder.set_role(Role::ColorWell);
360 let color = self.color.get();
361 builder.set_color_value(color);
362 let hex = color.to_hex_upper(color.a() < 1.0);
363 let name = match &self.label {
364 Some(ls) => ls.resolve_now(),
365 None => {
366 resolve_message_widget("color-picker-swatch-label", &[("hex", hex.clone().into())])
367 }
368 };
369 let display = if self.selected {
370 let suffix = resolve_message_widget("color-picker-swatch-selected-suffix", &[]);
371 format!("{}{}", name, suffix)
372 } else {
373 name
374 };
375 builder.set_name(display);
376 builder.set_value(hex);
377 if self.selected {
378 builder.set_selected(true);
379 }
380 builder.add_action(Action::Click);
382 builder.add_action(Action::Focus);
383 }
384
385 fn place_children(
386 &self,
387 _bounds: Rect,
388 _proposal: SizeProposal,
389 _children: &mut [WidgetPlacement],
390 _ctx: &LayoutContext,
391 ) {
392 }
393}