teksilo_widgets/color_edit.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ColorEdit` — compact field-style color picker trigger that opens
5//! a popover containing a [`ColorPicker`].
6//!
7//! Direct analog of [`DateEdit`](crate::date_edit::DateEdit). The
8//! trigger is a [`Button`] with a reactive [`ColorSwatch`] in its
9//! leading slot, the current hex as the label, and an optional
10//! chevron in its trailing slot. Click, Enter, Space, or Alt+Down
11//! opens the popover; Escape or click-outside closes it. The inner
12//! picker writes through the same bound `Signal<Color>`, so external
13//! observers see live updates as the user drags within the popover
14//! (no commit step).
15//!
16//! Built on [`PopoverButton`]:
17//! the overlay wiring (dormant content + show / dismiss + AT
18//! `has_popup` + `expanded`) lives there. This file is just the
19//! ColorEdit-specific assembly — picker config pass-through, the
20//! reactive trigger, and the nullable-binding bridge.
21//!
22//! # Accessibility
23//!
24//! The trigger declares `Role::Button`
25//! (via Button), `HasPopup::Dialog`
26//! (via PopoverButton), and tracks the popover open state through
27//! `set_expanded`. The label binds reactively to the hex value so
28//! AT name updates as the picker mutates the bound color.
29//!
30//! # Example
31//!
32//! ```ignore
33//! use teksilo_core::signal::Signal;
34//! use teksilo_tokens::Color;
35//!
36//! let color = ctx.signal(Color::new(0.21, 0.52, 0.89, 1.0));
37//! let _edit = ColorEdit::new(color)
38//! .alpha_enabled(true)
39//! .show_chevron(true);
40//! ```
41
42use std::rc::Rc;
43use teksilo_i18n::lit;
44
45use teksilo_canvas::{Rect, SizeProposal};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::build_context::BuildContext;
48use teksilo_core::overlay::{DismissBehavior, OverlayPlacement};
49use teksilo_core::signal::{Prop, Signal};
50use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
51use teksilo_core::widget_builder::WidgetBuilder;
52use teksilo_core::widget_id::WidgetId;
53use teksilo_i18n::{LocalizedString, resolve_message_widget};
54use teksilo_tokens::Color;
55
56use crate::button::Button;
57use crate::color_picker::{ColorPicker, ColorPickerLayout, ColorSwatch};
58use crate::popover_widget::PopoverButton;
59use crate::primitives::IconWidget;
60
61type OnVoid = Rc<dyn Fn()>;
62
63#[derive(Clone)]
64enum ColorBinding {
65 Required(Signal<Color>),
66 Nullable {
67 source: Signal<Option<Color>>,
68 proxy: Signal<Color>,
69 },
70}
71
72impl ColorBinding {
73 fn proxy(&self) -> Signal<Color> {
74 match self {
75 Self::Required(s) => s.clone(),
76 Self::Nullable { proxy, .. } => proxy.clone(),
77 }
78 }
79}
80
81/// Compact color cell that opens a full [`ColorPicker`] in a popover when activated.
82pub struct ColorEdit {
83 binding: ColorBinding,
84
85 // Picker pass-through.
86 alpha_enabled: bool,
87 swatches: Option<Prop<Vec<Color>>>,
88 swatch_columns: usize,
89 picker_layout: ColorPickerLayout,
90 show_rgb_spinners: bool,
91 show_hsv_spinners: bool,
92 show_hex_input: bool,
93
94 // Trigger appearance.
95 show_hex_in_trigger: bool,
96 show_chevron: bool,
97 trigger_swatch_size: Option<f32>,
98
99 // Popover.
100 placement: OverlayPlacement,
101 dismiss_behavior: DismissBehavior,
102
103 // Composite.
104 label: Option<LocalizedString>,
105 /// Enabled state, static or reactive; forwarded to the arena at
106 /// build time.
107 enabled: Prop<bool>,
108 on_open: Option<OnVoid>,
109 on_close: Option<OnVoid>,
110
111 // Tooltip slots (mutually exclusive; last setter wins).
112 /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
113 /// with the rich / composite slots — every setter clears the other two so
114 /// the last call wins.
115 tooltip_text: Option<LocalizedString>,
116 /// Optional rich tooltip source (registry key or inline content).
117 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
118 /// Optional composite tooltip body (arbitrary widget tree).
119 composite_tooltip_content: Option<Box<dyn Widget>>,
120
121 // Internal state.
122 root_child_id: Option<WidgetId>,
123}
124
125impl std::fmt::Debug for ColorEdit {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("ColorEdit")
128 .field("alpha_enabled", &self.alpha_enabled)
129 .field("picker_layout", &self.picker_layout)
130 .field("enabled", &self.enabled.get())
131 .finish_non_exhaustive()
132 }
133}
134
135impl ColorEdit {
136 /// Bind to a non-nullable color signal. The trigger and the picker
137 /// both read from and write to the same signal.
138 pub fn new(value: Signal<Color>) -> Self {
139 Self::from_binding(ColorBinding::Required(value))
140 }
141
142 /// Bind to a nullable color signal. `None` is treated as transparent
143 /// black for picker math; any user interaction produces a concrete
144 /// `Some(color)`. To clear back to `None`, compose a separate
145 /// Clear button alongside the `ColorEdit`.
146 pub fn nullable(value: Signal<Option<Color>>) -> Self {
147 let proxy = Signal::new(value.get().unwrap_or(Color::TRANSPARENT));
148 Self::from_binding(ColorBinding::Nullable {
149 source: value,
150 proxy,
151 })
152 }
153
154 fn from_binding(binding: ColorBinding) -> Self {
155 Self {
156 binding,
157 alpha_enabled: false,
158 swatches: None,
159 swatch_columns: 6,
160 picker_layout: ColorPickerLayout::Compact,
161 show_rgb_spinners: true,
162 show_hsv_spinners: false,
163 show_hex_input: true,
164 show_hex_in_trigger: true,
165 show_chevron: true,
166 trigger_swatch_size: None,
167 placement: OverlayPlacement::BelowPreferred,
168 dismiss_behavior: DismissBehavior::EscapeOrClickOutside,
169 label: None,
170 enabled: Prop::Static(true),
171 on_open: None,
172 on_close: None,
173 tooltip_text: None,
174 rich_tooltip_source: None,
175 composite_tooltip_content: None,
176 root_child_id: None,
177 }
178 }
179
180 /// Enable or disable the alpha channel in the picker and the hex trigger label.
181 pub fn alpha_enabled(mut self, enabled: bool) -> Self {
182 self.alpha_enabled = enabled;
183 self
184 }
185
186 /// Provide a palette of preset swatches shown in the popover —
187 /// statically, or reactively via a bound `Signal<Vec<Color>>` so the
188 /// palette updates without reopening the popover.
189 pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self {
190 self.swatches = Some(s.into());
191 self
192 }
193
194 /// Number of columns in the preset swatch grid. Defaults to 6;
195 /// clamped to at least 1.
196 pub fn swatch_columns(mut self, n: usize) -> Self {
197 self.swatch_columns = n.max(1);
198 self
199 }
200
201 /// Select a popover layout variant — [`ColorPickerLayout::Compact`]
202 /// (default, minimal height) or `Standard` / `Wide` for richer controls.
203 pub fn picker_layout(mut self, l: ColorPickerLayout) -> Self {
204 self.picker_layout = l;
205 self
206 }
207
208 /// Show or hide the RGB (0–255) component spinners in the popover.
209 pub fn show_rgb_spinners(mut self, s: bool) -> Self {
210 self.show_rgb_spinners = s;
211 self
212 }
213
214 /// Show or hide the HSV (hue/saturation/value) component spinners in the popover.
215 pub fn show_hsv_spinners(mut self, s: bool) -> Self {
216 self.show_hsv_spinners = s;
217 self
218 }
219
220 /// Show or hide the hex string input in the popover.
221 pub fn show_hex_input(mut self, s: bool) -> Self {
222 self.show_hex_input = s;
223 self
224 }
225
226 /// Show or hide the formatted hex value as the trigger button label.
227 pub fn show_hex_in_trigger(mut self, s: bool) -> Self {
228 self.show_hex_in_trigger = s;
229 self
230 }
231
232 /// Show or hide the trailing chevron glyph on the trigger button.
233 pub fn show_chevron(mut self, s: bool) -> Self {
234 self.show_chevron = s;
235 self
236 }
237
238 /// Override the size of the color swatch thumbnail in the trigger button (logical pixels).
239 pub fn trigger_swatch_size(mut self, size: f32) -> Self {
240 self.trigger_swatch_size = Some(size.max(0.0));
241 self
242 }
243
244 /// Override where the popover appears relative to the trigger.
245 /// Default is [`OverlayPlacement::BelowPreferred`].
246 pub fn placement(mut self, p: OverlayPlacement) -> Self {
247 self.placement = p;
248 self
249 }
250
251 /// Override how the popover is dismissed. Default is
252 /// `DismissBehavior::EscapeOrClickOutside`.
253 pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self {
254 self.dismiss_behavior = b;
255 self
256 }
257
258 /// Replace the trigger button's visible label with a static localized
259 /// string. When set, the hex value is no longer displayed in the trigger
260 /// (combine with `.show_hex_in_trigger(false)` if needed).
261 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
262 self.label = Some(label.into());
263 self
264 }
265
266 /// Set the enabled state, statically or reactively. Forwarded to the
267 /// arena at build time.
268 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
269 self.enabled = enabled.into();
270 self
271 }
272
273 /// Install a callback fired when the color-picker popover opens.
274 ///
275 /// The signature is `Fn()` (no [`EventContext`](teksilo_core::widget::EventContext))
276 /// because `on_close` is invoked from the overlay-dismiss path,
277 /// which has no ctx in scope. To keep the open/close pair
278 /// symmetric, `on_open` matches. If you need ctx in a
279 /// color-editing-mode callback, attach an `on_tap` on a sibling
280 /// trigger that wakes the editor explicitly.
281 pub fn on_open(mut self, f: impl Fn() + 'static) -> Self {
282 self.on_open = Some(Rc::new(f));
283 self
284 }
285
286 /// Install a callback fired when the color-picker popover closes.
287 /// See [`on_open`](Self::on_open) for why this is `Fn()` and not
288 /// `Fn(&mut EventContext)`.
289 pub fn on_close(mut self, f: impl Fn() + 'static) -> Self {
290 self.on_close = Some(Rc::new(f));
291 self
292 }
293
294 /// Attach a plain single-line tooltip shown after a hover delay.
295 ///
296 /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
297 /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
298 /// [`composite_tooltip`](Self::composite_tooltip) — calling this
299 /// clears the other slots (last setter wins).
300 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
301 self.tooltip_text = Some(text.into());
302 self.rich_tooltip_source = None;
303 self.composite_tooltip_content = None;
304 self
305 }
306
307 /// Attach a rich tooltip identified by a registry key.
308 ///
309 /// Mutually exclusive with [`tooltip`](Self::tooltip),
310 /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
311 /// [`composite_tooltip`](Self::composite_tooltip) — calling this
312 /// clears the other slots (last setter wins).
313 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
314 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
315 self.tooltip_text = None;
316 self.composite_tooltip_content = None;
317 self
318 }
319
320 /// Attach a rich tooltip from an inline [`TooltipContent`](crate::tooltip::TooltipContent) value.
321 ///
322 /// Mutually exclusive with [`tooltip`](Self::tooltip),
323 /// [`rich_tooltip`](Self::rich_tooltip), and
324 /// [`composite_tooltip`](Self::composite_tooltip) — calling this
325 /// clears the other slots (last setter wins).
326 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
327 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
328 self.tooltip_text = None;
329 self.composite_tooltip_content = None;
330 self
331 }
332
333 /// Attach a composite tooltip whose body is an arbitrary widget tree.
334 ///
335 /// Mutually exclusive with [`tooltip`](Self::tooltip),
336 /// [`rich_tooltip`](Self::rich_tooltip), and
337 /// [`rich_tooltip_content`](Self::rich_tooltip_content) — calling
338 /// this clears the other slots (last setter wins).
339 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
340 self.composite_tooltip_content = Some(Box::new(content));
341 self.tooltip_text = None;
342 self.rich_tooltip_source = None;
343 self
344 }
345}
346
347impl Widget for ColorEdit {
348 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
349 let self_id = ctx.self_id();
350 // Forward the enabled state into the arena; see IconButton.
351 ctx.enabled_when(self_id, self.enabled.clone());
352
353 // Bridge nullable binding ↔ proxy. The picker writes the
354 // proxy; we mirror that to the source as Some(c). External
355 // changes to source flow back into proxy. Empty (None) state
356 // is purely visual on the trigger — there is no in-trigger
357 // "clear" affordance (apps compose a separate Clear button).
358 if let ColorBinding::Nullable { source, proxy } = &self.binding {
359 {
360 let proxy = proxy.clone();
361 ctx.effect(source, move |new| {
362 let resolved = new.unwrap_or(Color::TRANSPARENT);
363 if proxy.get() != resolved {
364 proxy.set(resolved);
365 }
366 });
367 }
368 {
369 let source = source.clone();
370 ctx.effect(proxy, move |c| {
371 if source.get() != Some(*c) {
372 source.set(Some(*c));
373 }
374 });
375 }
376 }
377
378 let value = self.binding.proxy();
379 let alpha_enabled = self.alpha_enabled;
380 use crate::styles::recipe_color_picker_style as cp;
381
382 // Snapshot of the bound color at popover-open time. Cancel
383 // restores this; Done leaves the picker's writes intact. The
384 // initial value seeds the snapshot for the first open before
385 // the open-transition effect has a chance to refresh it.
386 let snapshot = ctx.signal(value.get());
387
388 // ── Build the picker (handed to PopoverButton as content) ──
389 let mut picker = ColorPicker::new(value.clone())
390 .alpha_enabled(alpha_enabled)
391 .layout(self.picker_layout)
392 .show_rgb_spinners(self.show_rgb_spinners)
393 .show_hsv_spinners(self.show_hsv_spinners)
394 .show_hex_input(self.show_hex_input)
395 .swatch_columns(self.swatch_columns)
396 .show_footer(true)
397 .on_done(|ctx_evt| {
398 ctx_evt.dismiss_self_overlay_chain();
399 })
400 .on_cancel({
401 let value = value.clone();
402 let snapshot = snapshot.clone();
403 move |ctx_evt| {
404 let prior = snapshot.get();
405 if value.get() != prior {
406 value.set(prior);
407 }
408 ctx_evt.dismiss_self_overlay_chain();
409 }
410 });
411 if let Some(s) = self.swatches.clone() {
412 picker = picker.swatches(s);
413 }
414
415 // ── Build the trigger ──
416 let swatch_size = self.trigger_swatch_size.unwrap_or(cp::PREVIEW_HEIGHT);
417
418 // ColorSwatch accepts `impl Into<Prop<Color>>` — pass the
419 // bound signal so it re-paints whenever the picker mutates
420 // the value. `.access_hidden(true)` so the swatch's own
421 // ColorWell role doesn't appear as a redundant child of the
422 // trigger Button's Role::Button.
423 let swatch = ColorSwatch::new(value.clone())
424 .size(swatch_size)
425 .corner_radius(cp::PREVIEW_CORNER_RADIUS)
426 .enabled(false)
427 .access_hidden(true);
428
429 // Reactive hex / placeholder for the Button label. For the
430 // nullable variant, None → localized "—" placeholder; Some →
431 // formatted hex. For the required variant, just formatted hex.
432 let label_signal = match &self.binding {
433 ColorBinding::Required(s) => {
434 let alpha = alpha_enabled;
435 if self.show_hex_in_trigger {
436 s.map(move |c| c.to_hex_upper(alpha))
437 } else {
438 s.map(|_| String::new())
439 }
440 }
441 ColorBinding::Nullable { source, .. } => {
442 let alpha = alpha_enabled;
443 let show_hex = self.show_hex_in_trigger;
444 // Zip the locale signal so the "no color" placeholder
445 // re-resolves on a live locale switch — `source.map` alone
446 // only re-fires when the color changes.
447 source
448 .zip(&ctx.locale_signal())
449 .map(move |(opt, _)| match opt {
450 Some(c) if show_hex => c.to_hex_upper(alpha),
451 Some(_) => String::new(),
452 None => resolve_message_widget("color-edit-trigger-empty-placeholder", &[]),
453 })
454 }
455 };
456
457 // App-supplied `.label(...)` replaces the entire visible text
458 // (and therefore the AT name) with a static localized string.
459 // Apps that want their custom label PLUS a visible swatch can
460 // pair `.label(...)` with `.show_hex_in_trigger(false)`. When
461 // no label is set, the bound hex signal feeds the Button label
462 // — every value mutation refreshes the visible text and the
463 // AT name reactively via Button's `label` plumbing.
464 let trigger = if let Some(ls) = self.label.take() {
465 Button::new(ls)
466 } else {
467 Button::new(lit!("")).label(label_signal)
468 };
469 let mut trigger = trigger.leading(swatch);
470 if self.show_chevron {
471 trigger = trigger.trailing(IconWidget::chevron_down(12.0).access_hidden(true));
472 }
473
474 // ── Wrap in PopoverButton ──
475 let pb = PopoverButton::new(trigger)
476 .content(picker)
477 .placement(self.placement.clone())
478 .dismiss_behavior(self.dismiss_behavior.clone());
479
480 // Refresh the cancel-snapshot whenever the popover transitions
481 // to open. This must run BEFORE the user has a chance to
482 // mutate the value through the picker — `open_signal()` flips
483 // synchronously inside the activate handler, before any drag
484 // events reach the canvas / strips, so the snapshot captures
485 // the value that was bound at the moment of open.
486 {
487 let open_signal = pb.open_signal();
488 let snapshot = snapshot.clone();
489 let value = value.clone();
490 ctx.effect(&open_signal, move |opened| {
491 if *opened {
492 let current = value.get();
493 if snapshot.get() != current {
494 snapshot.set(current);
495 }
496 }
497 });
498 }
499
500 let mut pb = pb;
501 if let Some(cb) = self.on_open.take() {
502 pb = pb.on_open(move || cb());
503 }
504 if let Some(cb) = self.on_close.take() {
505 pb = pb.on_close(move || cb());
506 }
507
508 let pb_id = ctx.add(pb);
509 self.root_child_id = Some(pb_id);
510
511 // Tooltip attachment — anchored on the trigger (pb_id), not the popover content.
512 if let Some(content) = self.composite_tooltip_content.take() {
513 let delay = ctx.theme().motion.tooltip_delay_heavy;
514 crate::tooltip::attach_composite_tooltip_boxed(ctx, pb_id, content, delay);
515 } else if let Some(source) = self.rich_tooltip_source.clone() {
516 let delay = ctx.theme().motion.tooltip_delay;
517 crate::tooltip::attach_rich_tooltip_source(ctx, pb_id, source, delay);
518 } else if let Some(text) = self.tooltip_text.clone() {
519 let delay = ctx.theme().motion.tooltip_delay;
520 crate::tooltip::attach_plain_tooltip(ctx, pb_id, text, delay);
521 }
522
523 vec![pb_id]
524 }
525
526 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
527 match self.root_child_id {
528 Some(id) => ctx
529 .child_layout_response(id, proposal)
530 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
531 None => proposal.resolve(0.0, 0.0).into(),
532 }
533 }
534
535 fn place_children(
536 &self,
537 bounds: Rect,
538 _proposal: SizeProposal,
539 children: &mut [WidgetPlacement],
540 _ctx: &LayoutContext,
541 ) {
542 for child in children.iter_mut() {
543 child.origin = bounds.origin();
544 child.size = bounds.size();
545 }
546 }
547
548 fn children(&self) -> Vec<WidgetId> {
549 self.root_child_id.into_iter().collect()
550 }
551
552 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
553 // Transparent — the inner Button (via PopoverButton) declares
554 // Role::Button + has_popup + expanded + name. Adding anything
555 // here would create a duplicate AT element above the trigger.
556 }
557}