Skip to main content

teksilo_widgets/
hex_color_input.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `HexColorInput` — single-line `#RRGGBB[AA]` color editor.
5//!
6//! A specialization of [`TextInput`] that wires an input mask, a
7//! hex-digit character filter, and a strict commit-time validator on top
8//! of the standard text-editing surface. Bound to a `Signal<Color>`
9//! (required) or `Signal<Option<Color>>` (nullable). External writes to
10//! the bound signal reformat the field text — but only when the field
11//! is unfocused, so a user typing "FF" in the middle of a long color
12//! code isn't clobbered by a sibling widget tweaking the value.
13//!
14//! # Behaviour
15//!
16//! - **Parsing**: `#RRGGBB` (case-insensitive); `#RRGGBBAA` if
17//!   `alpha_enabled`; `#RGB` short-form expands to `#RRGGBB` if
18//!   `short_form_enabled`. Each accepted form may be normalized to
19//!   uppercase on commit (configurable).
20//! - **Char filter**: only `[0-9a-fA-F#]` admitted while typing.
21//! - **Mask**: `\\#hhhhhh` (or `\\#hhhhhhhh` with alpha) — the
22//!   `TextInputField` mask grammar (`h` = hex digit slot, `\\` literal
23//!   escape).
24//! - **Validation**: commits on Enter / Tab-out / blur.  Returns
25//!   [`ValidationOutcome::Valid`] / [`ValidationOutcome::Corrected`] /
26//!   [`ValidationOutcome::Invalid`] which the inner field maps to a
27//!   visible inline strip via the standard
28//!   `validation_feedback` bridge.
29//! - **Nullable**: empty (after trim) commits `None`; non-empty
30//!   parses normally and commits `Some(color)`.
31//!
32//! # Example
33//!
34//! ```ignore
35//! let color = ctx.signal(Color::from_hex("#3584E4"));
36//! ctx.add(
37//!     HexColorInput::new(color)
38//!         .alpha_enabled(true)
39//!         .label("Background"),
40//! );
41//! ```
42
43use 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/// Internal binding — the widget bridges to either a non-nullable
65/// `Signal<Color>` (where empty commits revert to the previous color)
66/// or a nullable `Signal<Option<Color>>` (where empty commits store
67/// `None`).
68#[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                // None on a required binding is silently ignored — the
89                // validator already returns Invalid for empty input on
90                // required signals so this branch is unreachable in
91                // practice.
92            }
93            Self::Nullable(s) => {
94                s.set(value);
95            }
96        }
97    }
98
99    /// Subscribe `f` to be called whenever the bound value changes.
100    /// Bridge between the two binding shapes so the focused-reformat
101    /// effect doesn't need to know which variant it has.
102    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
114/// Single-line hex color editor.
115pub 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 state, static or reactive; forwarded to the arena at
124    /// build time.
125    enabled: Prop<bool>,
126    read_only: bool,
127    width: Option<f32>,
128    on_value_changed: Option<OnValueChanged>,
129    on_invalid: Option<OnInvalid>,
130    /// Lazily created in [`Widget::build`]; mirrored by the inner
131    /// TextInput's wiring + by the focused-reformat effect.
132    text_signal: Signal<String>,
133    /// Lazily set during build — true while the inner field has focus.
134    focused: Signal<bool>,
135    /// Mirrored from the inner TextInput's
136    /// `validation_feedback_signal()` so external observers can react
137    /// to commit feedback.
138    feedback: Signal<ValidationFeedback>,
139    /// Inner widget id captured during build for layout forwarding.
140    root_child_id: Option<WidgetId>,
141    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
142    /// with the rich / composite slots — every setter clears the other two so
143    /// the last call wins.
144    tooltip_text: Option<LocalizedString>,
145    /// Optional rich tooltip source (registry key or inline content).
146    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
147    /// Optional composite tooltip body (arbitrary widget tree).
148    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    /// Bind to a non-nullable color signal. Empty / invalid input
166    /// surfaces an error and keeps the previous value. Commits on
167    /// Enter or blur.
168    pub fn new(value: Signal<Color>) -> Self {
169        let initial = value.get();
170        Self::from_binding(HexValueBinding::Required(value), Some(initial))
171    }
172
173    /// Bind to a nullable color signal. Empty input commits `None`;
174    /// invalid input surfaces an error and keeps the previous value.
175    /// Commits on Enter or blur.
176    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    /// Enable or disable the alpha channel (`#RRGGBBAA` form). Default `false`
211    /// (`#RRGGBB` only). When enabled, the input mask and parser both switch
212    /// to the 8-digit form; existing values are immediately reformatted.
213    pub fn alpha_enabled(mut self, enabled: bool) -> Self {
214        self.alpha_enabled = enabled;
215        // Re-seed text in the new shape so the widget renders consistently
216        // before build() runs.
217        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    /// Allow CSS `#RGB` short-form input (each digit doubles: `#F0A` →
225    /// `#FF00AA`). Default `true`. When committed, the short form is expanded
226    /// and a `Corrected` feedback is shown to the user.
227    pub fn short_form_enabled(mut self, enabled: bool) -> Self {
228        self.short_form_enabled = enabled;
229        self
230    }
231
232    /// Require the `#` prefix during input. Default `true`. Set to `false`
233    /// to accept bare `RRGGBB` hex digits (e.g. CSS custom property editors).
234    pub fn require_hash(mut self, required: bool) -> Self {
235        self.require_hash = required;
236        self
237    }
238
239    /// Normalize committed values to uppercase hex digits. Default `true`
240    /// (`#FF0000`). Set to `false` for lowercase (`#ff0000`). Existing
241    /// values are reformatted immediately.
242    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    /// Attach a visible label above the field and use it as the AT name.
252    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
253        self.label = Some(label.into());
254        self
255    }
256
257    /// Placeholder text shown when the field is empty. Defaults to the
258    /// framework's locale-specific `#RRGGBB` / `#RRGGBBAA` hint.
259    pub fn placeholder(mut self, placeholder: impl Into<LocalizedString>) -> Self {
260        self.placeholder = Some(placeholder.into());
261        self
262    }
263
264    /// Set the enabled state, statically or reactively. Forwarded to the
265    /// arena at build time.
266    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
267        self.enabled = enabled.into();
268        self
269    }
270
271    /// Put the field in read-only mode; the value is displayed but cannot be
272    /// edited. Forwarded to the inner `TextInput`.
273    pub fn read_only(mut self, read_only: bool) -> Self {
274        self.read_only = read_only;
275        self
276    }
277
278    /// Set a minimum intrinsic width for the field in logical pixels.
279    pub fn width(mut self, width: f32) -> Self {
280        self.width = Some(width.max(0.0));
281        self
282    }
283
284    /// Called after a successful commit with the new color value (`None` on a
285    /// nullable binding when the field is cleared). Not called when the previous
286    /// and new values are identical.
287    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    /// Called after a commit attempt when the input is invalid, with the raw
296    /// typed string. The field is left as-is so the user can correct the value.
297    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    /// Attach a plain single-line tooltip shown after the standard hover delay.
306    ///
307    /// Mutually exclusive with [`Self::rich_tooltip`], [`Self::rich_tooltip_content`],
308    /// and [`Self::composite_tooltip`] — each setter clears the other three so
309    /// the last call wins.
310    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    /// Attach a rich tooltip driven by a registry key.
318    ///
319    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip_content`],
320    /// and [`Self::composite_tooltip`] — each setter clears the other three so
321    /// the last call wins.
322    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    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`].
330    ///
331    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
332    /// and [`Self::composite_tooltip`] — each setter clears the other three so
333    /// the last call wins.
334    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    /// Attach a composite tooltip whose body is an arbitrary widget tree.
342    ///
343    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
344    /// and [`Self::rich_tooltip_content`] — each setter clears the other three so
345    /// the last call wins.
346    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    /// Reactive handle on the inner TextInput's published validation
354    /// feedback. Mirrors the inner field's signal after `build()`.
355    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        // Forward the enabled state into the arena; see IconButton.
364        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        // External writes → reformat (skip while focused, mirror DateEdit).
384        {
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        // Validator — pure classification. Side-effects (writing back
402        // to the bound signal, firing on_value_changed / on_invalid)
403        // happen in the on_blur / on_submit chain because the validator
404        // closure can't see EventContext.
405        //
406        // Invalid messages capture the raw input via a shared cell so
407        // the on_blur handler can pass it to on_invalid.
408        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                            // Distinguish "expanded short-form" from
429                            // "case normalized" for the message.
430                            let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed);
431                            let was_short_form = short_form_enabled && stripped.len() == 3;
432                            // Capture owned copies: the `localized` closure is
433                            // `'static`, so it can't borrow `trimmed`, and
434                            // `normalized` is still needed for `corrected`.
435                            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        // Commit closure — runs on Enter or focus loss after the
469        // validator. If feedback is `Invalid`, leave the typed text
470        // alone (don't silently revert; same DateEdit policy). On
471        // valid / corrected commits, parse the (possibly-rewritten)
472        // text and write back to the bound signal + fire callbacks.
473        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        // Build the inner TextInput composite. The validator + char
507        // filter + mask cooperate: char filter strips garbage as the
508        // user types, mask enforces shape, validator runs on commit.
509        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        // Mirror the inner field's published feedback into our own
541        // signal so external observers (e.g. ColorPicker, ColorEdit)
542        // can react.
543        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        // Mirror the inner field's role; the TextInput composite carries
598        // GenericContainer, so the wrapper retitles to TextInput so AT
599        // users land on a recognized text-edit role at this depth.
600        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        // Framework a11y walker sets `set_disabled` from arena state.
624        if self.read_only {
625            builder.set_read_only();
626        }
627    }
628}
629
630// ── Helpers (free functions so closures can capture by clone) ────────
631
632fn 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
659/// Strict hex parser. Returns `Err` instead of silently producing BLACK
660/// the way [`Color::from_hex`] does, so the validator can surface a
661/// meaningful error message.
662fn 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            // Each digit doubles: F → FF, 5 → 55. (CSS shorthand convention.)
682            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        // #abc → #aabbcc → 0xAA, 0xBB, 0xCC
739        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        // 0.5 * 255 ≈ 127.5 → rounds to 128 = 0x80
803        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        // Confirm parse_hex itself rejects empty input — the
812        // nullable-empty-is-valid behavior lives in the validator
813        // closure inside build(), not in parse_hex.
814        let err = parse_hex("", false, true, true);
815        assert!(err.is_err());
816    }
817}