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    /// Created with the widget and re-seeded by the `alpha_enabled` /
131    /// `uppercase` setters; mirrored by the inner TextInput's wiring + by the
132    /// focused-reformat effect.
133    text_signal: Signal<String>,
134    /// Always `false` today: unlike `DateEdit`, which wires its twin through
135    /// `.focus_within(..)`, nothing here hands this signal to the inner field —
136    /// so the focused-reformat guard in `build` never engages.
137    focused: Signal<bool>,
138    /// Mirrored from the inner TextInput's
139    /// `validation_feedback_signal()` so external observers can react
140    /// to commit feedback.
141    feedback: Signal<ValidationFeedback>,
142    /// Inner widget id captured during build for layout forwarding.
143    root_child_id: Option<WidgetId>,
144    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
145    /// with the rich / composite slots — every setter clears the other two so
146    /// the last call wins.
147    tooltip_text: Option<LocalizedString>,
148    /// Optional rich tooltip source (registry key or inline content).
149    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
150    /// Optional composite tooltip body (arbitrary widget tree).
151    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    /// Bind to a non-nullable color signal. Empty / invalid input
169    /// surfaces an error and keeps the previous value. Commits on
170    /// Enter or blur.
171    pub fn new(value: Signal<Color>) -> Self {
172        let initial = value.get();
173        Self::from_binding(HexValueBinding::Required(value), Some(initial))
174    }
175
176    /// Bind to a nullable color signal. Empty input commits `None`;
177    /// invalid input surfaces an error and keeps the previous value.
178    /// Commits on Enter or blur.
179    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    /// Enable or disable the alpha channel (`#RRGGBBAA` form). Default `false`
214    /// (`#RRGGBB` only). When enabled, the input mask and parser both switch
215    /// to the 8-digit form; existing values are immediately reformatted.
216    pub fn alpha_enabled(mut self, enabled: bool) -> Self {
217        self.alpha_enabled = enabled;
218        // Re-seed text in the new shape so the widget renders consistently
219        // before build() runs.
220        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    /// Allow CSS `#RGB` short-form input (each digit doubles: `#F0A` →
228    /// `#FF00AA`). Default `true`. When committed, the short form is expanded
229    /// and a `Corrected` feedback is shown to the user.
230    pub fn short_form_enabled(mut self, enabled: bool) -> Self {
231        self.short_form_enabled = enabled;
232        self
233    }
234
235    /// Require the `#` prefix during input. Default `true`. Set to `false`
236    /// to accept bare `RRGGBB` hex digits (e.g. CSS custom property editors).
237    pub fn require_hash(mut self, required: bool) -> Self {
238        self.require_hash = required;
239        self
240    }
241
242    /// Normalize committed values to uppercase hex digits. Default `true`
243    /// (`#FF0000`). Set to `false` for lowercase (`#ff0000`). Existing
244    /// values are reformatted immediately.
245    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    /// Attach a visible label above the field and use it as the AT name.
255    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
256        self.label = Some(label.into());
257        self
258    }
259
260    /// Placeholder text shown when the field is empty. Defaults to the
261    /// framework's locale-specific `#RRGGBB` / `#RRGGBBAA` hint.
262    pub fn placeholder(mut self, placeholder: impl Into<LocalizedString>) -> Self {
263        self.placeholder = Some(placeholder.into());
264        self
265    }
266
267    /// Set the enabled state, statically or reactively. Forwarded to the
268    /// arena at build time.
269    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
270        self.enabled = enabled.into();
271        self
272    }
273
274    /// Put the field in read-only mode; the value is displayed but cannot be
275    /// edited. Forwarded to the inner `TextInput`.
276    pub fn read_only(mut self, read_only: bool) -> Self {
277        self.read_only = read_only;
278        self
279    }
280
281    /// Set a minimum intrinsic width for the field in logical pixels.
282    pub fn width(mut self, width: f32) -> Self {
283        self.width = Some(width.max(0.0));
284        self
285    }
286
287    /// Called after a successful commit with the new color value (`None` on a
288    /// nullable binding when the field is cleared). Not called when the previous
289    /// and new values are identical.
290    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    /// Called after a commit attempt when the input is invalid, with the raw
299    /// typed string. The field is left as-is so the user can correct the value.
300    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    /// Attach a plain single-line tooltip shown after the standard hover delay.
309    ///
310    /// Mutually exclusive with [`Self::rich_tooltip`], [`Self::rich_tooltip_content`],
311    /// and [`Self::composite_tooltip`] — each setter clears the other three so
312    /// the last call wins.
313    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    /// Attach a rich tooltip driven by a registry key.
321    ///
322    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip_content`],
323    /// and [`Self::composite_tooltip`] — each setter clears the other three so
324    /// the last call wins.
325    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    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`].
333    ///
334    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
335    /// and [`Self::composite_tooltip`] — each setter clears the other three so
336    /// the last call wins.
337    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    /// Attach a composite tooltip whose body is an arbitrary widget tree.
345    ///
346    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
347    /// and [`Self::rich_tooltip_content`] — each setter clears the other three so
348    /// the last call wins.
349    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    /// Reactive handle on the inner TextInput's published validation
357    /// feedback. Mirrors the inner field's signal after `build()`.
358    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        // Forward the enabled state into the arena; see IconButton.
367        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        // External writes → reformat (skip while focused, mirror DateEdit).
387        {
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        // Validator — pure classification. Side-effects (writing back
405        // to the bound signal, firing on_value_changed / on_invalid)
406        // happen in the on_blur / on_submit chain because the validator
407        // closure can't see EventContext.
408        //
409        // Invalid messages capture the raw input via a shared cell so
410        // the on_blur handler can pass it to on_invalid.
411        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                            // Distinguish "expanded short-form" from
432                            // "case normalized" for the message.
433                            let stripped = trimmed.strip_prefix('#').unwrap_or(trimmed);
434                            let was_short_form = short_form_enabled && stripped.len() == 3;
435                            // Capture owned copies: the `localized` closure is
436                            // `'static`, so it can't borrow `trimmed`, and
437                            // `normalized` is still needed for `corrected`.
438                            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        // Commit closure — runs on Enter or focus loss after the
472        // validator. If feedback is `Invalid`, leave the typed text
473        // alone (don't silently revert; same DateEdit policy). On
474        // valid / corrected commits, parse the (possibly-rewritten)
475        // text and write back to the bound signal + fire callbacks.
476        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        // Build the inner TextInput composite. The validator + char
510        // filter + mask cooperate: char filter strips garbage as the
511        // user types, mask enforces shape, validator runs on commit.
512        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        // Mirror the inner field's published feedback into our own
544        // signal so external observers (e.g. ColorPicker, ColorEdit)
545        // can react.
546        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        // Mirror the inner field's role; the TextInput composite carries
601        // GenericContainer, so the wrapper retitles to TextInput so AT
602        // users land on a recognized text-edit role at this depth.
603        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        // Framework a11y walker sets `set_disabled` from arena state.
627        if self.read_only {
628            builder.set_read_only();
629        }
630    }
631}
632
633// ── Helpers (free functions so closures can capture by clone) ────────
634
635fn 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
662/// Strict hex parser. Returns `Err` instead of silently producing BLACK
663/// the way [`Color::from_hex`] does, so the validator can surface a
664/// meaningful error message.
665fn 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            // Each digit doubles: F → FF, 5 → 55. (CSS shorthand convention.)
685            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        // #abc → #aabbcc → 0xAA, 0xBB, 0xCC
742        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        // 0.5 * 255 ≈ 127.5 → rounds to 128 = 0x80
806        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        // Confirm parse_hex itself rejects empty input — the
815        // nullable-empty-is-valid behavior lives in the validator
816        // closure inside build(), not in parse_hex.
817        let err = parse_hex("", false, true, true);
818        assert!(err.is_err());
819    }
820}