1use std::cell::RefCell;
44use std::rc::Rc;
45use teksilo_i18n::lit;
46
47use teksilo_canvas::SizeProposal;
48use teksilo_core::accessibility::AccessNodeBuilder;
49use teksilo_core::accesskit::Role;
50use teksilo_core::build_context::BuildContext;
51use teksilo_core::signal::{Prop, Signal};
52use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
53use teksilo_core::widget_id::WidgetId;
54use teksilo_i18n::{localized, resolve_message_widget};
55use teksilo_tokens::Color;
56
57use crate::primitives::text_input_field::{ValidationFeedback, ValidationOutcome, ValidatorFn};
58use crate::text_input::TextInput;
59use teksilo_i18n::LocalizedString;
60
61type OnValueChanged = Rc<dyn Fn(Option<Color>, &mut teksilo_core::widget::EventContext)>;
62type OnInvalid = Rc<dyn Fn(&str, &mut teksilo_core::widget::EventContext)>;
63
64#[derive(Clone)]
69enum HexValueBinding {
70 Required(Signal<Color>),
71 Nullable(Signal<Option<Color>>),
72}
73
74impl HexValueBinding {
75 fn current(&self) -> Option<Color> {
76 match self {
77 Self::Required(s) => Some(s.get()),
78 Self::Nullable(s) => s.get(),
79 }
80 }
81
82 fn set(&self, value: Option<Color>) {
83 match self {
84 Self::Required(s) => {
85 if let Some(c) = value {
86 s.set(c);
87 }
88 }
93 Self::Nullable(s) => {
94 s.set(value);
95 }
96 }
97 }
98
99 fn observe_with_effect<F: Fn(Option<Color>) + 'static>(&self, ctx: &mut BuildContext, f: F) {
103 match self {
104 Self::Required(s) => {
105 ctx.effect(s, move |c| f(Some(*c)));
106 }
107 Self::Nullable(s) => {
108 ctx.effect(s, move |c| f(*c));
109 }
110 }
111 }
112}
113
114pub struct HexColorInput {
116 value: HexValueBinding,
117 alpha_enabled: bool,
118 short_form_enabled: bool,
119 require_hash: bool,
120 uppercase: bool,
121 label: Option<LocalizedString>,
122 placeholder: Option<LocalizedString>,
123 enabled: Prop<bool>,
126 read_only: bool,
127 width: Option<f32>,
128 on_value_changed: Option<OnValueChanged>,
129 on_invalid: Option<OnInvalid>,
130 text_signal: Signal<String>,
133 focused: Signal<bool>,
135 feedback: Signal<ValidationFeedback>,
139 root_child_id: Option<WidgetId>,
141 tooltip_text: Option<LocalizedString>,
145 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
147 composite_tooltip_content: Option<Box<dyn Widget>>,
149}
150
151impl std::fmt::Debug for HexColorInput {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("HexColorInput")
154 .field("alpha_enabled", &self.alpha_enabled)
155 .field("short_form_enabled", &self.short_form_enabled)
156 .field("require_hash", &self.require_hash)
157 .field("uppercase", &self.uppercase)
158 .field("enabled", &self.enabled.get())
159 .field("read_only", &self.read_only)
160 .finish_non_exhaustive()
161 }
162}
163
164impl HexColorInput {
165 pub fn new(value: Signal<Color>) -> Self {
169 let initial = value.get();
170 Self::from_binding(HexValueBinding::Required(value), Some(initial))
171 }
172
173 pub fn nullable(value: Signal<Option<Color>>) -> Self {
177 let initial = value.get();
178 Self::from_binding(HexValueBinding::Nullable(value), initial)
179 }
180
181 fn from_binding(binding: HexValueBinding, initial: Option<Color>) -> Self {
182 let alpha_enabled = false;
183 let uppercase = true;
184 let initial_text = initial
185 .map(|c| format_hex(c, alpha_enabled, uppercase))
186 .unwrap_or_default();
187 Self {
188 value: binding,
189 alpha_enabled,
190 short_form_enabled: true,
191 require_hash: true,
192 uppercase,
193 label: None,
194 placeholder: None,
195 enabled: Prop::Static(true),
196 read_only: false,
197 width: None,
198 on_value_changed: None,
199 on_invalid: None,
200 text_signal: Signal::new(initial_text),
201 focused: Signal::new(false),
202 feedback: Signal::new(ValidationFeedback::Pristine),
203 root_child_id: None,
204 tooltip_text: None,
205 rich_tooltip_source: None,
206 composite_tooltip_content: None,
207 }
208 }
209
210 pub fn alpha_enabled(mut self, enabled: bool) -> Self {
214 self.alpha_enabled = enabled;
215 if let Some(c) = self.value.current() {
218 self.text_signal
219 .set(format_hex(c, self.alpha_enabled, self.uppercase));
220 }
221 self
222 }
223
224 pub fn short_form_enabled(mut self, enabled: bool) -> Self {
228 self.short_form_enabled = enabled;
229 self
230 }
231
232 pub fn require_hash(mut self, required: bool) -> Self {
235 self.require_hash = required;
236 self
237 }
238
239 pub fn uppercase(mut self, upper: bool) -> Self {
243 self.uppercase = upper;
244 if let Some(c) = self.value.current() {
245 self.text_signal
246 .set(format_hex(c, self.alpha_enabled, self.uppercase));
247 }
248 self
249 }
250
251 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
253 self.label = Some(label.into());
254 self
255 }
256
257 pub fn placeholder(mut self, placeholder: impl Into<LocalizedString>) -> Self {
260 self.placeholder = Some(placeholder.into());
261 self
262 }
263
264 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
267 self.enabled = enabled.into();
268 self
269 }
270
271 pub fn read_only(mut self, read_only: bool) -> Self {
274 self.read_only = read_only;
275 self
276 }
277
278 pub fn width(mut self, width: f32) -> Self {
280 self.width = Some(width.max(0.0));
281 self
282 }
283
284 pub fn on_value_changed(
288 mut self,
289 f: impl Fn(Option<Color>, &mut teksilo_core::widget::EventContext) + 'static,
290 ) -> Self {
291 self.on_value_changed = Some(Rc::new(f));
292 self
293 }
294
295 pub fn on_invalid(
298 mut self,
299 f: impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static,
300 ) -> Self {
301 self.on_invalid = Some(Rc::new(f));
302 self
303 }
304
305 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
311 self.tooltip_text = Some(text.into());
312 self.rich_tooltip_source = None;
313 self.composite_tooltip_content = None;
314 self
315 }
316
317 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
323 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
324 self.tooltip_text = None;
325 self.composite_tooltip_content = None;
326 self
327 }
328
329 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
335 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
336 self.tooltip_text = None;
337 self.composite_tooltip_content = None;
338 self
339 }
340
341 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
347 self.composite_tooltip_content = Some(Box::new(content));
348 self.tooltip_text = None;
349 self.rich_tooltip_source = None;
350 self
351 }
352
353 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
356 self.feedback.clone()
357 }
358}
359
360impl Widget for HexColorInput {
361 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
362 let self_id = ctx.self_id();
363 ctx.enabled_when(self_id, self.enabled.clone());
365 let alpha_enabled = self.alpha_enabled;
366 let short_form_enabled = self.short_form_enabled;
367 let require_hash = self.require_hash;
368 let uppercase = self.uppercase;
369 let nullable = matches!(self.value, HexValueBinding::Nullable(_));
370
371 let placeholder = self
372 .placeholder
373 .clone()
374 .map(|ls| ls.resolve_now())
375 .unwrap_or_else(|| {
376 if alpha_enabled {
377 resolve_message_widget("hex-color-input-placeholder-with-alpha", &[])
378 } else {
379 resolve_message_widget("hex-color-input-placeholder", &[])
380 }
381 });
382
383 {
385 let text_signal = self.text_signal.clone();
386 let focused = self.focused.clone();
387 self.value.observe_with_effect(ctx, move |new_value| {
388 if focused.get() {
389 return;
390 }
391 let formatted = match new_value {
392 Some(c) => format_hex(c, alpha_enabled, uppercase),
393 None => String::new(),
394 };
395 if text_signal.get() != formatted {
396 text_signal.set(formatted);
397 }
398 });
399 }
400
401 let last_raw: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
409 let validator: ValidatorFn = {
410 let last_raw = last_raw.clone();
411 Rc::new(move |raw: &str| -> ValidationOutcome {
412 *last_raw.borrow_mut() = raw.to_string();
413 let trimmed = raw.trim();
414 if trimmed.is_empty() {
415 if nullable {
416 return ValidationOutcome::Valid;
417 }
418 return ValidationOutcome::Invalid {
419 message: invalid_message(alpha_enabled),
420 };
421 }
422 match parse_hex(trimmed, alpha_enabled, short_form_enabled, require_hash) {
423 Ok(parsed) => {
424 let normalized = format_hex(parsed, alpha_enabled, uppercase);
425 if normalized == trimmed {
426 ValidationOutcome::Valid
427 } else {
428 let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed);
431 let was_short_form = short_form_enabled && stripped.len() == 3;
432 let raw_owned = trimmed.to_string();
436 let value_owned = normalized.clone();
437 let message = if was_short_form {
438 localized(move || {
439 resolve_message_widget(
440 "hex-color-input-corrected-shortform",
441 &[
442 ("raw", raw_owned.clone().into()),
443 ("value", value_owned.clone().into()),
444 ],
445 )
446 })
447 } else {
448 localized(move || {
449 resolve_message_widget(
450 "hex-color-input-corrected-uppercase",
451 &[("value", value_owned.clone().into())],
452 )
453 })
454 };
455 ValidationOutcome::Corrected {
456 corrected: normalized,
457 message,
458 }
459 }
460 }
461 Err(_) => ValidationOutcome::Invalid {
462 message: invalid_message(alpha_enabled),
463 },
464 }
465 })
466 };
467
468 let commit: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)> = {
474 let value_binding = self.value.clone();
475 let text_signal = self.text_signal.clone();
476 let feedback_signal = self.feedback.clone();
477 let on_value_changed = self.on_value_changed.clone();
478 let on_invalid = self.on_invalid.clone();
479 let last_raw = last_raw.clone();
480 Rc::new(move |ctx_evt: &mut teksilo_core::widget::EventContext| {
481 let fb = feedback_signal.get();
482 if matches!(fb, ValidationFeedback::Invalid { .. }) {
483 if let Some(cb) = on_invalid.as_ref() {
484 let raw = last_raw.borrow().clone();
485 cb(&raw, ctx_evt);
486 }
487 return;
488 }
489 let raw = text_signal.get();
490 let trimmed = raw.trim();
491 let new_value: Option<Color> = if trimmed.is_empty() {
492 None
493 } else {
494 parse_hex(trimmed, alpha_enabled, short_form_enabled, require_hash).ok()
495 };
496 let prev = value_binding.current();
497 if prev != new_value {
498 value_binding.set(new_value);
499 if let Some(cb) = on_value_changed.as_ref() {
500 cb(new_value, ctx_evt);
501 }
502 }
503 })
504 };
505
506 let mask_string = if alpha_enabled {
510 r"\#hhhhhhhh"
511 } else {
512 r"\#hhhhhh"
513 };
514
515 let mut text_input = TextInput::new(self.text_signal.clone())
516 .placeholder(lit!(placeholder))
517 .enabled(self.enabled.get())
518 .read_only(self.read_only)
519 .input_mask(mask_string.to_string())
520 .char_filter(|c: char| c.is_ascii_hexdigit() || c == '#')
521 .validator({
522 let v = validator.clone();
523 move |s| (v)(s)
524 })
525 .on_submit_fn({
526 let commit = commit.clone();
527 move |ctx_evt| commit(ctx_evt)
528 })
529 .on_blur_fn({
530 let commit = commit.clone();
531 move |ctx_evt| commit(ctx_evt)
532 });
533 if let Some(label) = self.label.clone() {
534 text_input = text_input.label(lit!(label.resolve_now()));
535 }
536 if let Some(w) = self.width {
537 text_input = text_input.min_width(w);
538 }
539
540 let feedback_in = text_input.validation_feedback_signal();
544 {
545 let feedback_out = self.feedback.clone();
546 ctx.effect(&feedback_in, move |fb| {
547 if feedback_out.get() != *fb {
548 feedback_out.set(fb.clone());
549 }
550 });
551 }
552
553 let root_id = ctx.add(text_input);
554 self.root_child_id = Some(root_id);
555
556 if let Some(content) = self.composite_tooltip_content.take() {
557 let delay = ctx.theme().motion.tooltip_delay_heavy;
558 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
559 } else if let Some(source) = self.rich_tooltip_source.clone() {
560 let delay = ctx.theme().motion.tooltip_delay;
561 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
562 } else if let Some(text) = self.tooltip_text.clone() {
563 let delay = ctx.theme().motion.tooltip_delay;
564 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
565 }
566
567 vec![root_id]
568 }
569
570 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
571 match self.root_child_id {
572 Some(id) => ctx
573 .child_layout_response(id, proposal)
574 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
575 None => proposal.resolve(0.0, 0.0).into(),
576 }
577 }
578
579 fn place_children(
580 &self,
581 bounds: teksilo_canvas::Rect,
582 _proposal: SizeProposal,
583 children: &mut [WidgetPlacement],
584 _ctx: &LayoutContext,
585 ) {
586 for child in children.iter_mut() {
587 child.origin = bounds.origin();
588 child.size = bounds.size();
589 }
590 }
591
592 fn children(&self) -> Vec<WidgetId> {
593 self.root_child_id.into_iter().collect()
594 }
595
596 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
597 builder.set_role(Role::TextInput);
601 if let Some(ref label) = self.label {
602 builder.set_name(label.resolve_now());
603 }
604 match self.value.current() {
605 Some(c) => {
606 builder.set_value(format_hex(c, self.alpha_enabled, self.uppercase));
607 }
608 None => {
609 let placeholder = self
610 .placeholder
611 .clone()
612 .map(|ls| ls.resolve_now())
613 .unwrap_or_else(|| {
614 if self.alpha_enabled {
615 resolve_message_widget("hex-color-input-placeholder-with-alpha", &[])
616 } else {
617 resolve_message_widget("hex-color-input-placeholder", &[])
618 }
619 });
620 builder.set_placeholder(placeholder);
621 }
622 }
623 if self.read_only {
625 builder.set_read_only();
626 }
627 }
628}
629
630fn format_hex(color: Color, alpha_enabled: bool, uppercase: bool) -> String {
633 if uppercase {
634 color.to_hex_upper(alpha_enabled)
635 } else {
636 color.to_hex_lower(alpha_enabled)
637 }
638}
639
640fn invalid_message(alpha_enabled: bool) -> LocalizedString {
641 let key = if alpha_enabled {
642 "hex-color-input-invalid-with-alpha"
643 } else {
644 "hex-color-input-invalid"
645 };
646 localized(move || resolve_message_widget(key, &[]))
647}
648
649#[derive(Debug, thiserror::Error)]
650enum ParseError {
651 #[error("missing `#` prefix")]
652 MissingHash,
653 #[error("invalid hex length")]
654 InvalidLength,
655 #[error("invalid hex digit")]
656 InvalidDigit,
657}
658
659fn parse_hex(
663 input: &str,
664 alpha_enabled: bool,
665 short_form_enabled: bool,
666 require_hash: bool,
667) -> Result<Color, ParseError> {
668 let body = match input.strip_prefix('#') {
669 Some(rest) => rest,
670 None if require_hash => return Err(ParseError::MissingHash),
671 None => input,
672 };
673
674 let parse_byte = |s: &str| -> Result<u8, ParseError> {
675 u8::from_str_radix(s, 16).map_err(|_| ParseError::InvalidDigit)
676 };
677
678 match body.len() {
679 3 if short_form_enabled => {
680 let chars: Vec<char> = body.chars().collect();
681 let r = parse_byte(&format!("{0}{0}", chars[0]))?;
683 let g = parse_byte(&format!("{0}{0}", chars[1]))?;
684 let b = parse_byte(&format!("{0}{0}", chars[2]))?;
685 Ok(Color::from_rgb(
686 r as f32 / 255.0,
687 g as f32 / 255.0,
688 b as f32 / 255.0,
689 ))
690 }
691 6 => {
692 let r = parse_byte(&body[0..2])?;
693 let g = parse_byte(&body[2..4])?;
694 let b = parse_byte(&body[4..6])?;
695 Ok(Color::from_rgb(
696 r as f32 / 255.0,
697 g as f32 / 255.0,
698 b as f32 / 255.0,
699 ))
700 }
701 8 if alpha_enabled => {
702 let r = parse_byte(&body[0..2])?;
703 let g = parse_byte(&body[2..4])?;
704 let b = parse_byte(&body[4..6])?;
705 let a = parse_byte(&body[6..8])?;
706 Ok(Color::from_rgba(
707 r as f32 / 255.0,
708 g as f32 / 255.0,
709 b as f32 / 255.0,
710 a as f32 / 255.0,
711 ))
712 }
713 _ => Err(ParseError::InvalidLength),
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720
721 #[test]
722 fn parse_full_form_uppercase() {
723 let c = parse_hex("#FF0000", false, true, true).unwrap();
724 assert!((c.r() - 1.0).abs() < 0.01);
725 assert!(c.g().abs() < 0.01);
726 assert!(c.b().abs() < 0.01);
727 }
728
729 #[test]
730 fn parse_full_form_lowercase() {
731 let c = parse_hex("#ff0000", false, true, true).unwrap();
732 assert!((c.r() - 1.0).abs() < 0.01);
733 }
734
735 #[test]
736 fn parse_short_form_expands() {
737 let c = parse_hex("#abc", false, true, true).unwrap();
738 assert!((c.r() - (0xAA as f32 / 255.0)).abs() < 0.01);
740 assert!((c.g() - (0xBB as f32 / 255.0)).abs() < 0.01);
741 assert!((c.b() - (0xCC as f32 / 255.0)).abs() < 0.01);
742 }
743
744 #[test]
745 fn parse_no_hash_when_required_fails() {
746 let err = parse_hex("FF0000", false, true, true);
747 assert!(matches!(err, Err(ParseError::MissingHash)));
748 }
749
750 #[test]
751 fn parse_no_hash_when_optional_succeeds() {
752 let c = parse_hex("FF0000", false, true, false).unwrap();
753 assert!((c.r() - 1.0).abs() < 0.01);
754 }
755
756 #[test]
757 fn parse_alpha_form() {
758 let c = parse_hex("#FF000080", true, true, true).unwrap();
759 assert!((c.r() - 1.0).abs() < 0.01);
760 assert!((c.a() - 0.5).abs() < 0.01);
761 }
762
763 #[test]
764 fn parse_alpha_form_rejected_when_disabled() {
765 let err = parse_hex("#FF000080", false, true, true);
766 assert!(matches!(err, Err(ParseError::InvalidLength)));
767 }
768
769 #[test]
770 fn parse_invalid_chars() {
771 let err = parse_hex("#GGGGGG", false, true, true);
772 assert!(matches!(err, Err(ParseError::InvalidDigit)));
773 }
774
775 #[test]
776 fn parse_wrong_lengths() {
777 for input in &["#FF00", "#FF000", "#FF00000"] {
778 let err = parse_hex(input, false, true, true);
779 assert!(
780 matches!(err, Err(ParseError::InvalidLength)),
781 "expected InvalidLength for {input}"
782 );
783 }
784 }
785
786 #[test]
787 fn format_uppercase_default() {
788 let s = format_hex(Color::RED, false, true);
789 assert_eq!(s, "#FF0000");
790 }
791
792 #[test]
793 fn format_lowercase() {
794 let s = format_hex(Color::RED, false, false);
795 assert_eq!(s, "#ff0000");
796 }
797
798 #[test]
799 fn format_alpha_form() {
800 let c = Color::from_rgba(1.0, 0.0, 0.0, 0.5);
801 let s = format_hex(c, true, true);
802 assert_eq!(s, "#FF000080");
804 }
805
806 #[test]
807 fn nullable_empty_input_is_valid() {
808 let signal: Signal<Option<Color>> = Signal::new(None);
809 let widget = HexColorInput::nullable(signal.clone());
810 assert!(matches!(widget.value, HexValueBinding::Nullable(_)));
811 let err = parse_hex("", false, true, true);
815 assert!(err.is_err());
816 }
817}