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>,
134 focused: Signal<bool>,
138 feedback: Signal<ValidationFeedback>,
142 root_child_id: Option<WidgetId>,
144 tooltip_text: Option<LocalizedString>,
148 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
150 composite_tooltip_content: Option<Box<dyn Widget>>,
152}
153
154impl std::fmt::Debug for HexColorInput {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("HexColorInput")
157 .field("alpha_enabled", &self.alpha_enabled)
158 .field("short_form_enabled", &self.short_form_enabled)
159 .field("require_hash", &self.require_hash)
160 .field("uppercase", &self.uppercase)
161 .field("enabled", &self.enabled.get())
162 .field("read_only", &self.read_only)
163 .finish_non_exhaustive()
164 }
165}
166
167impl HexColorInput {
168 pub fn new(value: Signal<Color>) -> Self {
172 let initial = value.get();
173 Self::from_binding(HexValueBinding::Required(value), Some(initial))
174 }
175
176 pub fn nullable(value: Signal<Option<Color>>) -> Self {
180 let initial = value.get();
181 Self::from_binding(HexValueBinding::Nullable(value), initial)
182 }
183
184 fn from_binding(binding: HexValueBinding, initial: Option<Color>) -> Self {
185 let alpha_enabled = false;
186 let uppercase = true;
187 let initial_text = initial
188 .map(|c| format_hex(c, alpha_enabled, uppercase))
189 .unwrap_or_default();
190 Self {
191 value: binding,
192 alpha_enabled,
193 short_form_enabled: true,
194 require_hash: true,
195 uppercase,
196 label: None,
197 placeholder: None,
198 enabled: Prop::Static(true),
199 read_only: false,
200 width: None,
201 on_value_changed: None,
202 on_invalid: None,
203 text_signal: Signal::new(initial_text),
204 focused: Signal::new(false),
205 feedback: Signal::new(ValidationFeedback::Pristine),
206 root_child_id: None,
207 tooltip_text: None,
208 rich_tooltip_source: None,
209 composite_tooltip_content: None,
210 }
211 }
212
213 pub fn alpha_enabled(mut self, enabled: bool) -> Self {
217 self.alpha_enabled = enabled;
218 if let Some(c) = self.value.current() {
221 self.text_signal
222 .set(format_hex(c, self.alpha_enabled, self.uppercase));
223 }
224 self
225 }
226
227 pub fn short_form_enabled(mut self, enabled: bool) -> Self {
231 self.short_form_enabled = enabled;
232 self
233 }
234
235 pub fn require_hash(mut self, required: bool) -> Self {
238 self.require_hash = required;
239 self
240 }
241
242 pub fn uppercase(mut self, upper: bool) -> Self {
246 self.uppercase = upper;
247 if let Some(c) = self.value.current() {
248 self.text_signal
249 .set(format_hex(c, self.alpha_enabled, self.uppercase));
250 }
251 self
252 }
253
254 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
256 self.label = Some(label.into());
257 self
258 }
259
260 pub fn placeholder(mut self, placeholder: impl Into<LocalizedString>) -> Self {
263 self.placeholder = Some(placeholder.into());
264 self
265 }
266
267 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
270 self.enabled = enabled.into();
271 self
272 }
273
274 pub fn read_only(mut self, read_only: bool) -> Self {
277 self.read_only = read_only;
278 self
279 }
280
281 pub fn width(mut self, width: f32) -> Self {
283 self.width = Some(width.max(0.0));
284 self
285 }
286
287 pub fn on_value_changed(
291 mut self,
292 f: impl Fn(Option<Color>, &mut teksilo_core::widget::EventContext) + 'static,
293 ) -> Self {
294 self.on_value_changed = Some(Rc::new(f));
295 self
296 }
297
298 pub fn on_invalid(
301 mut self,
302 f: impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static,
303 ) -> Self {
304 self.on_invalid = Some(Rc::new(f));
305 self
306 }
307
308 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
314 self.tooltip_text = Some(text.into());
315 self.rich_tooltip_source = None;
316 self.composite_tooltip_content = None;
317 self
318 }
319
320 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
326 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
327 self.tooltip_text = None;
328 self.composite_tooltip_content = None;
329 self
330 }
331
332 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
338 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
339 self.tooltip_text = None;
340 self.composite_tooltip_content = None;
341 self
342 }
343
344 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
350 self.composite_tooltip_content = Some(Box::new(content));
351 self.tooltip_text = None;
352 self.rich_tooltip_source = None;
353 self
354 }
355
356 pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
359 self.feedback.clone()
360 }
361}
362
363impl Widget for HexColorInput {
364 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
365 let self_id = ctx.self_id();
366 ctx.enabled_when(self_id, self.enabled.clone());
368 let alpha_enabled = self.alpha_enabled;
369 let short_form_enabled = self.short_form_enabled;
370 let require_hash = self.require_hash;
371 let uppercase = self.uppercase;
372 let nullable = matches!(self.value, HexValueBinding::Nullable(_));
373
374 let placeholder = self
375 .placeholder
376 .clone()
377 .map(|ls| ls.resolve_now())
378 .unwrap_or_else(|| {
379 if alpha_enabled {
380 resolve_message_widget("hex-color-input-placeholder-with-alpha", &[])
381 } else {
382 resolve_message_widget("hex-color-input-placeholder", &[])
383 }
384 });
385
386 {
388 let text_signal = self.text_signal.clone();
389 let focused = self.focused.clone();
390 self.value.observe_with_effect(ctx, move |new_value| {
391 if focused.get() {
392 return;
393 }
394 let formatted = match new_value {
395 Some(c) => format_hex(c, alpha_enabled, uppercase),
396 None => String::new(),
397 };
398 if text_signal.get() != formatted {
399 text_signal.set(formatted);
400 }
401 });
402 }
403
404 let last_raw: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
412 let validator: ValidatorFn = {
413 let last_raw = last_raw.clone();
414 Rc::new(move |raw: &str| -> ValidationOutcome {
415 *last_raw.borrow_mut() = raw.to_string();
416 let trimmed = raw.trim();
417 if trimmed.is_empty() {
418 if nullable {
419 return ValidationOutcome::Valid;
420 }
421 return ValidationOutcome::Invalid {
422 message: invalid_message(alpha_enabled),
423 };
424 }
425 match parse_hex(trimmed, alpha_enabled, short_form_enabled, require_hash) {
426 Ok(parsed) => {
427 let normalized = format_hex(parsed, alpha_enabled, uppercase);
428 if normalized == trimmed {
429 ValidationOutcome::Valid
430 } else {
431 let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed);
434 let was_short_form = short_form_enabled && stripped.len() == 3;
435 let raw_owned = trimmed.to_string();
439 let value_owned = normalized.clone();
440 let message = if was_short_form {
441 localized(move || {
442 resolve_message_widget(
443 "hex-color-input-corrected-shortform",
444 &[
445 ("raw", raw_owned.clone().into()),
446 ("value", value_owned.clone().into()),
447 ],
448 )
449 })
450 } else {
451 localized(move || {
452 resolve_message_widget(
453 "hex-color-input-corrected-uppercase",
454 &[("value", value_owned.clone().into())],
455 )
456 })
457 };
458 ValidationOutcome::Corrected {
459 corrected: normalized,
460 message,
461 }
462 }
463 }
464 Err(_) => ValidationOutcome::Invalid {
465 message: invalid_message(alpha_enabled),
466 },
467 }
468 })
469 };
470
471 let commit: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)> = {
477 let value_binding = self.value.clone();
478 let text_signal = self.text_signal.clone();
479 let feedback_signal = self.feedback.clone();
480 let on_value_changed = self.on_value_changed.clone();
481 let on_invalid = self.on_invalid.clone();
482 let last_raw = last_raw.clone();
483 Rc::new(move |ctx_evt: &mut teksilo_core::widget::EventContext| {
484 let fb = feedback_signal.get();
485 if matches!(fb, ValidationFeedback::Invalid { .. }) {
486 if let Some(cb) = on_invalid.as_ref() {
487 let raw = last_raw.borrow().clone();
488 cb(&raw, ctx_evt);
489 }
490 return;
491 }
492 let raw = text_signal.get();
493 let trimmed = raw.trim();
494 let new_value: Option<Color> = if trimmed.is_empty() {
495 None
496 } else {
497 parse_hex(trimmed, alpha_enabled, short_form_enabled, require_hash).ok()
498 };
499 let prev = value_binding.current();
500 if prev != new_value {
501 value_binding.set(new_value);
502 if let Some(cb) = on_value_changed.as_ref() {
503 cb(new_value, ctx_evt);
504 }
505 }
506 })
507 };
508
509 let mask_string = if alpha_enabled {
513 r"\#hhhhhhhh"
514 } else {
515 r"\#hhhhhh"
516 };
517
518 let mut text_input = TextInput::new(self.text_signal.clone())
519 .placeholder(lit!(placeholder))
520 .enabled(self.enabled.get())
521 .read_only(self.read_only)
522 .input_mask(mask_string.to_string())
523 .char_filter(|c: char| c.is_ascii_hexdigit() || c == '#')
524 .validator({
525 let v = validator.clone();
526 move |s| (v)(s)
527 })
528 .on_submit_fn({
529 let commit = commit.clone();
530 move |ctx_evt| commit(ctx_evt)
531 })
532 .on_blur_fn({
533 let commit = commit.clone();
534 move |ctx_evt| commit(ctx_evt)
535 });
536 if let Some(label) = self.label.clone() {
537 text_input = text_input.label(lit!(label.resolve_now()));
538 }
539 if let Some(w) = self.width {
540 text_input = text_input.min_width(w);
541 }
542
543 let feedback_in = text_input.validation_feedback_signal();
547 {
548 let feedback_out = self.feedback.clone();
549 ctx.effect(&feedback_in, move |fb| {
550 if feedback_out.get() != *fb {
551 feedback_out.set(fb.clone());
552 }
553 });
554 }
555
556 let root_id = ctx.add(text_input);
557 self.root_child_id = Some(root_id);
558
559 if let Some(content) = self.composite_tooltip_content.take() {
560 let delay = ctx.theme().motion.tooltip_delay_heavy;
561 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
562 } else if let Some(source) = self.rich_tooltip_source.clone() {
563 let delay = ctx.theme().motion.tooltip_delay;
564 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
565 } else if let Some(text) = self.tooltip_text.clone() {
566 let delay = ctx.theme().motion.tooltip_delay;
567 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
568 }
569
570 vec![root_id]
571 }
572
573 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
574 match self.root_child_id {
575 Some(id) => ctx
576 .child_layout_response(id, proposal)
577 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
578 None => proposal.resolve(0.0, 0.0).into(),
579 }
580 }
581
582 fn place_children(
583 &self,
584 bounds: teksilo_canvas::Rect,
585 _proposal: SizeProposal,
586 children: &mut [WidgetPlacement],
587 _ctx: &LayoutContext,
588 ) {
589 for child in children.iter_mut() {
590 child.origin = bounds.origin();
591 child.size = bounds.size();
592 }
593 }
594
595 fn children(&self) -> Vec<WidgetId> {
596 self.root_child_id.into_iter().collect()
597 }
598
599 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
600 builder.set_role(Role::TextInput);
604 if let Some(ref label) = self.label {
605 builder.set_name(label.resolve_now());
606 }
607 match self.value.current() {
608 Some(c) => {
609 builder.set_value(format_hex(c, self.alpha_enabled, self.uppercase));
610 }
611 None => {
612 let placeholder = self
613 .placeholder
614 .clone()
615 .map(|ls| ls.resolve_now())
616 .unwrap_or_else(|| {
617 if self.alpha_enabled {
618 resolve_message_widget("hex-color-input-placeholder-with-alpha", &[])
619 } else {
620 resolve_message_widget("hex-color-input-placeholder", &[])
621 }
622 });
623 builder.set_placeholder(placeholder);
624 }
625 }
626 if self.read_only {
628 builder.set_read_only();
629 }
630 }
631}
632
633fn format_hex(color: Color, alpha_enabled: bool, uppercase: bool) -> String {
636 if uppercase {
637 color.to_hex_upper(alpha_enabled)
638 } else {
639 color.to_hex_lower(alpha_enabled)
640 }
641}
642
643fn invalid_message(alpha_enabled: bool) -> LocalizedString {
644 let key = if alpha_enabled {
645 "hex-color-input-invalid-with-alpha"
646 } else {
647 "hex-color-input-invalid"
648 };
649 localized(move || resolve_message_widget(key, &[]))
650}
651
652#[derive(Debug, thiserror::Error)]
653enum ParseError {
654 #[error("missing `#` prefix")]
655 MissingHash,
656 #[error("invalid hex length")]
657 InvalidLength,
658 #[error("invalid hex digit")]
659 InvalidDigit,
660}
661
662fn parse_hex(
666 input: &str,
667 alpha_enabled: bool,
668 short_form_enabled: bool,
669 require_hash: bool,
670) -> Result<Color, ParseError> {
671 let body = match input.strip_prefix('#') {
672 Some(rest) => rest,
673 None if require_hash => return Err(ParseError::MissingHash),
674 None => input,
675 };
676
677 let parse_byte = |s: &str| -> Result<u8, ParseError> {
678 u8::from_str_radix(s, 16).map_err(|_| ParseError::InvalidDigit)
679 };
680
681 match body.len() {
682 3 if short_form_enabled => {
683 let chars: Vec<char> = body.chars().collect();
684 let r = parse_byte(&format!("{0}{0}", chars[0]))?;
686 let g = parse_byte(&format!("{0}{0}", chars[1]))?;
687 let b = parse_byte(&format!("{0}{0}", chars[2]))?;
688 Ok(Color::from_rgb(
689 r as f32 / 255.0,
690 g as f32 / 255.0,
691 b as f32 / 255.0,
692 ))
693 }
694 6 => {
695 let r = parse_byte(&body[0..2])?;
696 let g = parse_byte(&body[2..4])?;
697 let b = parse_byte(&body[4..6])?;
698 Ok(Color::from_rgb(
699 r as f32 / 255.0,
700 g as f32 / 255.0,
701 b as f32 / 255.0,
702 ))
703 }
704 8 if alpha_enabled => {
705 let r = parse_byte(&body[0..2])?;
706 let g = parse_byte(&body[2..4])?;
707 let b = parse_byte(&body[4..6])?;
708 let a = parse_byte(&body[6..8])?;
709 Ok(Color::from_rgba(
710 r as f32 / 255.0,
711 g as f32 / 255.0,
712 b as f32 / 255.0,
713 a as f32 / 255.0,
714 ))
715 }
716 _ => Err(ParseError::InvalidLength),
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn parse_full_form_uppercase() {
726 let c = parse_hex("#FF0000", false, true, true).unwrap();
727 assert!((c.r() - 1.0).abs() < 0.01);
728 assert!(c.g().abs() < 0.01);
729 assert!(c.b().abs() < 0.01);
730 }
731
732 #[test]
733 fn parse_full_form_lowercase() {
734 let c = parse_hex("#ff0000", false, true, true).unwrap();
735 assert!((c.r() - 1.0).abs() < 0.01);
736 }
737
738 #[test]
739 fn parse_short_form_expands() {
740 let c = parse_hex("#abc", false, true, true).unwrap();
741 assert!((c.r() - (0xAA as f32 / 255.0)).abs() < 0.01);
743 assert!((c.g() - (0xBB as f32 / 255.0)).abs() < 0.01);
744 assert!((c.b() - (0xCC as f32 / 255.0)).abs() < 0.01);
745 }
746
747 #[test]
748 fn parse_no_hash_when_required_fails() {
749 let err = parse_hex("FF0000", false, true, true);
750 assert!(matches!(err, Err(ParseError::MissingHash)));
751 }
752
753 #[test]
754 fn parse_no_hash_when_optional_succeeds() {
755 let c = parse_hex("FF0000", false, true, false).unwrap();
756 assert!((c.r() - 1.0).abs() < 0.01);
757 }
758
759 #[test]
760 fn parse_alpha_form() {
761 let c = parse_hex("#FF000080", true, true, true).unwrap();
762 assert!((c.r() - 1.0).abs() < 0.01);
763 assert!((c.a() - 0.5).abs() < 0.01);
764 }
765
766 #[test]
767 fn parse_alpha_form_rejected_when_disabled() {
768 let err = parse_hex("#FF000080", false, true, true);
769 assert!(matches!(err, Err(ParseError::InvalidLength)));
770 }
771
772 #[test]
773 fn parse_invalid_chars() {
774 let err = parse_hex("#GGGGGG", false, true, true);
775 assert!(matches!(err, Err(ParseError::InvalidDigit)));
776 }
777
778 #[test]
779 fn parse_wrong_lengths() {
780 for input in &["#FF00", "#FF000", "#FF00000"] {
781 let err = parse_hex(input, false, true, true);
782 assert!(
783 matches!(err, Err(ParseError::InvalidLength)),
784 "expected InvalidLength for {input}"
785 );
786 }
787 }
788
789 #[test]
790 fn format_uppercase_default() {
791 let s = format_hex(Color::RED, false, true);
792 assert_eq!(s, "#FF0000");
793 }
794
795 #[test]
796 fn format_lowercase() {
797 let s = format_hex(Color::RED, false, false);
798 assert_eq!(s, "#ff0000");
799 }
800
801 #[test]
802 fn format_alpha_form() {
803 let c = Color::from_rgba(1.0, 0.0, 0.0, 0.5);
804 let s = format_hex(c, true, true);
805 assert_eq!(s, "#FF000080");
807 }
808
809 #[test]
810 fn nullable_empty_input_is_valid() {
811 let signal: Signal<Option<Color>> = Signal::new(None);
812 let widget = HexColorInput::nullable(signal.clone());
813 assert!(matches!(widget.value, HexValueBinding::Nullable(_)));
814 let err = parse_hex("", false, true, true);
818 assert!(err.is_err());
819 }
820}