Skip to main content

euv_core/vdom/attribute/
impl.rs

1use super::*;
2
3/// Implementation of the public read side of an attribute entry.
4impl AttributeEntry {
5    /// Returns the CSS class names this attribute carries.
6    ///
7    /// The `class` attribute is the one place where a component's
8    /// styling decisions become observable, so it needs a public read
9    /// side: a component library's tests have to be able to assert
10    /// that a state actually landed on the right class, and an external
11    /// crate cannot read the raw attribute value.
12    ///
13    /// Every shape the attribute can take is reported:
14    /// `Css` and `CssRef` contribute the class name they carry, `Text`
15    /// and `StaticText` contribute their string split on whitespace,
16    /// a `Signal` contributes the class list it currently holds — that
17    /// is the shape a reactive `class:` attribute takes — and the
18    /// remaining shapes, an event handler, contribute nothing, because
19    /// neither is a class list.
20    ///
21    /// # Returns
22    ///
23    /// - `Vec<String>` - The class names, in the order they appear.
24    pub fn class_names(&self) -> Vec<String> {
25        match self.get_value() {
26            AttributeValue::Css(css) => vec![css.get_name().clone()],
27            AttributeValue::CssRef(css) => vec![css.get_name().clone()],
28            AttributeValue::Text(text) => split_class_names(text),
29            AttributeValue::StaticText(text) => split_class_names(text),
30            AttributeValue::Signal(signal) => split_class_names(&signal.get()),
31            _ => Vec::new(),
32        }
33    }
34
35    /// Returns the attribute's value as text, when it carries one.
36    ///
37    /// A `Text` or `StaticText` attribute is its own value; a `Css` or
38    /// `CssRef` attribute contributes its class name, because that is
39    /// the text the browser ends up showing for it. The remaining
40    /// shapes — a reactive signal, an event handler — carry no text.
41    ///
42    /// # Returns
43    ///
44    /// - `Option<String>` - The value, when the attribute has one.
45    pub fn text_value(&self) -> Option<String> {
46        match self.get_value() {
47            AttributeValue::Text(text) => Some(text.clone()),
48            AttributeValue::StaticText(text) => Some((*text).to_string()),
49            AttributeValue::Css(css) => Some(css.get_name().clone()),
50            AttributeValue::CssRef(css) => Some(css.get_name().clone()),
51            _ => None,
52        }
53    }
54}
55
56/// Implementation of injected class tracking for CSS deduplication.
57impl InjectedClassesCell {
58    /// Returns `true` if the given class name has already been injected into
59    /// the DOM.
60    ///
61    /// # Arguments
62    ///
63    /// - `&str` - The CSS class name to check.
64    ///
65    /// # Returns
66    ///
67    /// - `bool` - Whether the class name has been injected.
68    pub(crate) fn is_injected(class_name: &str) -> bool {
69        INJECTED_CLASSES
70            .read()
71            .map(|classes: RwLockReadGuard<'_, HashSet<String>>| classes.contains(class_name))
72            .unwrap_or(false)
73    }
74
75    /// Marks a class name as injected so future calls to `is_injected`
76    /// return `true`.
77    ///
78    /// A poisoned lock (which only happens if another thread panicked while
79    /// holding the write guard) is recovered from rather than propagated:
80    /// the set is deduplication bookkeeping, so losing a lock must never
81    /// take down the render path.
82    ///
83    /// # Arguments
84    ///
85    /// - `&str` - The CSS class name to mark as injected.
86    pub(crate) fn mark_injected(class_name: &str) {
87        let write_result: Result<
88            InjectedClassesWriteGuard<'_>,
89            PoisonError<InjectedClassesWriteGuard<'_>>,
90        > = INJECTED_CLASSES.write();
91        let Ok(mut classes) = write_result else {
92            return;
93        };
94        classes.insert(class_name.to_string());
95    }
96}
97
98/// Implementation of attribute value factory methods for reactive and merged values.
99impl AttributeValue {
100    /// Creates a reactive attribute `Self` for conditional attribute values.
101    ///
102    /// This function replaces the inline `Signal::create(...)` boilerplate
103    /// that was previously generated by the `html!` macro for every
104    /// attribute value containing an `if` condition.
105    ///
106    /// The returned `Self::Signal` is a value carrier created fresh on each
107    /// render of the enclosing dynamic node: the signals read inside
108    /// `compute` during that render register the dynamic node as their
109    /// dependent, so any source change re-renders the node and rebuilds
110    /// this attribute with the new value. No registry listener is kept —
111    /// the former `subscribe_attr` registration was unreachable dead weight
112    /// (its key could never be marked dirty) and aliased the dynamic-node
113    /// key space of the update registry.
114    ///
115    /// # Arguments
116    ///
117    /// - `F` - A closure that computes the current attribute value.
118    ///   Called once per render of the enclosing dynamic node.
119    ///
120    /// # Returns
121    ///
122    /// - `Self` - A `Self::Signal` backed by a `Signal<String>`
123    ///   carrying the freshly computed attribute value.
124    pub fn reactive<F>(compute: F) -> Self
125    where
126        F: Fn() -> String + 'static,
127    {
128        let attr_signal: Signal<String> = Signal::create(compute());
129        Self::Signal(attr_signal)
130    }
131
132    /// Merges multiple class attribute values into a single `Self`.
133    ///
134    /// Each input value is adapted into a `Self` via `IntoReactiveValue`.
135    /// `Css` values are injected into the DOM and their names are collected.
136    /// All non-empty class names are joined with spaces into a final `Text` attribute.
137    /// If any value is signal-backed, the result becomes a `Signal` attribute
138    /// carrying the freshly merged value for this render; the enclosing
139    /// dynamic node re-renders on any constituent signal change (see
140    /// [`AttributeValue::reactive`]).
141    ///
142    /// # Arguments
143    ///
144    /// - `&[Self]` - The class attribute values to merge.
145    ///
146    /// # Returns
147    ///
148    /// - `Self` - A merged attribute value containing space-separated class names.
149    pub fn merge_class(values: &[Self]) -> Self {
150        let has_signal: bool = values
151            .iter()
152            .any(|value: &Self| matches!(value, Self::Signal(_)));
153        if has_signal {
154            let owned_values: Vec<Self> = values.to_vec();
155            let compute: Box<dyn Fn() -> String> =
156                Box::new(move || Self::join_class_segments(&owned_values));
157            let attr_signal: Signal<String> = Signal::create(compute());
158            return Self::Signal(attr_signal);
159        }
160        Self::Text(Self::join_class_segments(values))
161    }
162
163    /// Joins class attribute values into a single space-separated string.
164    ///
165    /// OPT-20: builds the result in a single `String` (amortized growth,
166    /// no intermediate `Vec<String>` + `join(" ")` round-trip), iterating
167    /// the input values by reference and cloning only the segments that
168    /// survive the filter (skipping `_ => None` arms and empty `Text`
169    /// strings), so the per-class render allocation drops from `N + 2`
170    /// to `1`.
171    ///
172    /// OPT-11: both `Css` and `CssRef` arms inject the style on first
173    /// reference. Without the `CssRef` arm the ref would fall through
174    /// to `_ => None` and silently drop out of the merged class list —
175    /// the regression that wiped every multi-class CssRef entry (e.g.
176    /// the `c_binding_slider` class next to a parameterized
177    /// `c_slider_value("30%")` on the same `<input>`).
178    ///
179    /// # Arguments
180    ///
181    /// - `&[Self]` - The class attribute values to merge, in order.
182    ///
183    /// # Returns
184    ///
185    /// - `String` - The merged class list, segments separated by a
186    ///   single space; empty when no segment survived filtering.
187    fn join_class_segments(values: &[Self]) -> String {
188        let mut joined: String = String::new();
189        for value in values.iter() {
190            let segment: Cow<'_, str> = match value {
191                Self::Css(css) => {
192                    css.inject_style();
193                    let name: &str = css.get_name();
194                    if name.is_empty() {
195                        continue;
196                    }
197                    Cow::Borrowed(name)
198                }
199                Self::CssRef(css) => {
200                    css.inject_style();
201                    let name: &str = css.get_name();
202                    if name.is_empty() {
203                        continue;
204                    }
205                    Cow::Borrowed(name)
206                }
207                Self::Text(text_value) => {
208                    if text_value.is_empty() {
209                        continue;
210                    }
211                    Cow::Borrowed(text_value.as_str())
212                }
213                Self::Signal(signal) => {
214                    let current: String = signal.get();
215                    if current.is_empty() {
216                        continue;
217                    }
218                    Cow::Owned(current)
219                }
220                _ => continue,
221            };
222            if !joined.is_empty() {
223                joined.push(' ');
224            }
225            joined.push_str(segment.as_ref());
226        }
227        joined
228    }
229
230    /// Merges multiple style attribute values into a single `Self`.
231    ///
232    /// Each input value is expected to be a style string (`Text`) or a reactive
233    /// `Signal<String>` producing a style string. All non-empty style strings are
234    /// joined with spaces into a final combined style attribute.
235    /// If any value is signal-backed, the result becomes a `Signal` attribute
236    /// carrying the freshly merged value for this render; the enclosing
237    /// dynamic node re-renders on any constituent signal change (see
238    /// [`AttributeValue::reactive`]).
239    ///
240    /// # Arguments
241    ///
242    /// - `&[Self]` - The style attribute values to merge.
243    ///
244    /// # Returns
245    ///
246    /// - `Self` - A merged attribute value containing the combined CSS style string.
247    pub fn merge_style(values: &[Self]) -> Self {
248        let has_signal: bool = values
249            .iter()
250            .any(|value: &Self| matches!(value, Self::Signal(_)));
251        if has_signal {
252            let owned_values: Vec<Self> = values.to_vec();
253            let compute: Box<dyn Fn() -> String> =
254                Box::new(move || Self::join_style_segments(&owned_values));
255            let attr_signal: Signal<String> = Signal::create(compute());
256            return Self::Signal(attr_signal);
257        }
258        Self::Text(Self::join_style_segments(values))
259    }
260
261    /// Joins style attribute values into a single space-separated string.
262    ///
263    /// OPT-20: same single-`String` approach as `join_class_segments`
264    /// (amortized growth), specialised to the `Text` + `Signal` cases that
265    /// style merging uses. Avoids the intermediate `Vec<String>` plus
266    /// `join(" ")` round-trip of the previous implementation, dropping
267    /// per-render allocation count from `N + 2` to `1`.
268    ///
269    /// # Arguments
270    ///
271    /// - `&[Self]` - The style attribute values to merge, in order.
272    ///
273    /// # Returns
274    ///
275    /// - `String` - The merged CSS declarations, segments separated by a
276    ///   single space; empty when no segment carried a declaration.
277    fn join_style_segments(values: &[Self]) -> String {
278        let mut joined: String = String::new();
279        for value in values.iter() {
280            let segment: Cow<'_, str> = match value {
281                Self::Text(text_value) => {
282                    if text_value.is_empty() {
283                        continue;
284                    }
285                    Cow::Borrowed(text_value.as_str())
286                }
287                Self::Signal(signal) => {
288                    let current: String = signal.get();
289                    if current.is_empty() {
290                        continue;
291                    }
292                    Cow::Owned(current)
293                }
294                _ => continue,
295            };
296            if !joined.is_empty() {
297                joined.push(' ');
298            }
299            joined.push_str(segment.as_ref());
300        }
301        joined
302    }
303
304    /// Converts a bool signal into a reactive boolean attribute value.
305    ///
306    /// Produces `AttributeValue::BoolSignal` directly: the renderer writes
307    /// `"true"` / `"false"` and subscribes the source signal to the element
308    /// with no intermediate mapping signal (the previous `Signal<String>`
309    /// bridge) and no per-render subscription.
310    ///
311    /// # Arguments
312    ///
313    /// - `Signal<bool>` - The source boolean signal.
314    ///
315    /// # Returns
316    ///
317    /// - `AttributeValue` - A `BoolSignal` wrapping the source signal.
318    pub(crate) fn bool_to_attr(source: Signal<bool>) -> AttributeValue {
319        AttributeValue::BoolSignal(source)
320    }
321}
322
323/// Visual equality comparison for attribute values.
324///
325/// Compares values by their visual output rather than identity. `Signal`
326/// values are compared by their current resolved string (borrow-compared
327/// via `Signal::with`, no `String` clones); when both signals share the
328/// same slab slot, they are always considered **unequal** — the patch walk
329/// recognises that case separately and skips the DOM write because the
330/// mount-time signal binding already owns the attribute's DOM value (see
331/// `patch_attributes`), while the unequal verdict keeps `visual_eq` from
332/// masking a value change between VDOM snapshots.
333/// `Event` values are always considered equal (re-binding is handled by the
334/// handler registry), and `Css` values are compared by class name.
335impl PartialEq for AttributeValue {
336    /// Compares two attribute values for visual equality.
337    ///
338    /// # Arguments
339    ///
340    /// - `&Self` - The first attribute value.
341    /// - `&Self` - The second attribute value.
342    ///
343    /// # Returns
344    ///
345    /// - `bool` - `true` if the values are visually equal.
346    fn eq(&self, other: &Self) -> bool {
347        match (self, other) {
348            (Self::Text(old_value), Self::Text(new_value)) => old_value == new_value,
349            (Self::StaticText(old_value), Self::StaticText(new_value)) => old_value == new_value,
350            (Self::Text(old_value), Self::StaticText(new_value)) => old_value == new_value,
351            (Self::StaticText(old_value), Self::Text(new_value)) => old_value == new_value,
352            (Self::Signal(old_signal), Self::Signal(new_signal)) => {
353                if old_signal.get_inner() == new_signal.get_inner() {
354                    return false;
355                }
356                old_signal.with(|old_value: &String| {
357                    new_signal.with(|new_value: &String| old_value == new_value)
358                })
359            }
360            (Self::Signal(old_signal), Self::Text(new_value)) => {
361                old_signal.with(|old_value: &String| old_value == new_value)
362            }
363            (Self::Text(old_value), Self::Signal(new_signal)) => {
364                new_signal.with(|new_value: &String| old_value == new_value)
365            }
366            (Self::BoolSignal(old_signal), Self::BoolSignal(new_signal)) => {
367                if old_signal.get_inner() == new_signal.get_inner() {
368                    return false;
369                }
370                old_signal.get() == new_signal.get()
371            }
372            (Self::Event(_), Self::Event(_)) => true,
373            (Self::Css(old_class), Self::Css(new_class)) => {
374                old_class.get_name() == new_class.get_name()
375            }
376            (Self::CssRef(old_class), Self::CssRef(new_class)) => {
377                old_class.get_name() == new_class.get_name()
378            }
379            (Self::CssRef(old_class), Self::Css(new_class)) => {
380                old_class.get_name() == new_class.get_name()
381            }
382            (Self::Css(old_class), Self::CssRef(new_class)) => {
383                old_class.get_name() == new_class.get_name()
384            }
385            (Self::Dynamic(old_dynamic), Self::Dynamic(new_dynamic)) => old_dynamic == new_dynamic,
386            _ => false,
387        }
388    }
389}
390
391/// Visual equality comparison for attribute entries.
392///
393/// Two attribute entries are equal when their names match and their values
394/// are visually equal as defined by `AttributeValue::eq`.
395impl PartialEq for AttributeEntry {
396    /// Compares two attribute entries for visual equality.
397    ///
398    /// # Arguments
399    ///
400    /// - `&Self` - The first attribute entry.
401    /// - `&Self` - The second attribute entry.
402    ///
403    /// # Returns
404    ///
405    /// - `bool` - `true` if both names and values match.
406    fn eq(&self, other: &Self) -> bool {
407        self.get_name() == other.get_name() && self.get_value() == other.get_value()
408    }
409}
410
411/// Visual equality comparison for CSS classes.
412///
413/// Two CSS classes are considered equal when their class names match,
414/// since the name uniquely identifies the visual style rule.
415impl PartialEq for Css {
416    /// Compares two CSS classes by name.
417    ///
418    /// # Arguments
419    ///
420    /// - `&Self` - The first CSS class.
421    /// - `&Self` - The second CSS class.
422    ///
423    /// # Returns
424    ///
425    /// - `bool` - `true` if the class names match.
426    fn eq(&self, other: &Self) -> bool {
427        self.get_name() == other.get_name()
428    }
429}
430
431/// Implementation of Css construction and style injection.
432impl Css {
433    /// Parses pseudo-class/pseudo-element rules from a compact serialization string.
434    ///
435    /// The serialization format is: `:selector { key: value; key: value; }:another { ... }`
436    /// This is used by the `class!` macro for fully static class definitions
437    /// where pseudo rules can be computed at compile time.
438    ///
439    /// # Arguments
440    ///
441    /// - `I` - The serialized pseudo rules string.
442    ///
443    /// # Returns
444    ///
445    /// - `Vec<PseudoRule>` - The parsed pseudo rules.
446    pub fn parse_pseudo_rules<I>(input: I) -> Vec<PseudoRule>
447    where
448        I: AsRef<str>,
449    {
450        let mut remaining: &str = input.as_ref();
451        let mut rules: Vec<PseudoRule> = Vec::new();
452        while !remaining.is_empty() {
453            let selector_end: Option<usize> = remaining.find(CSS_RULE_OPEN);
454            let Some(selector_end_index) = selector_end else {
455                break;
456            };
457            let selector: &str = &remaining[..selector_end_index];
458            let after_selector: &str = remaining[selector_end_index..]
459                .strip_prefix(CSS_RULE_OPEN)
460                .unwrap_or_default();
461            let style_end: Option<usize> = after_selector.find(CHAR_CSS_RULE_CLOSE);
462            let Some(style_end_index) = style_end else {
463                break;
464            };
465            let style: &str = &after_selector[..style_end_index];
466            if !selector.is_empty() && !style.is_empty() {
467                rules.push(PseudoRule::new(selector.to_string(), style.to_string()));
468            }
469            remaining = after_selector[style_end_index..]
470                .strip_prefix(CHAR_CSS_RULE_CLOSE)
471                .unwrap_or_default();
472        }
473        rules
474    }
475
476    /// Parses media query rules from a compact serialization string.
477    ///
478    /// The serialization format is:
479    /// `@media query { key: value; ::selector { key: value; } }@media query2 { ... }`
480    /// This is used by the `class!` macro for fully static class definitions
481    /// where media rules can be computed at compile time.
482    /// Supports nested pseudo-element blocks inside media query blocks.
483    ///
484    /// # Arguments
485    ///
486    /// - `S` - The serialized media rules string.
487    ///
488    /// # Returns
489    ///
490    /// - `Vec<MediaRule>` - The parsed media rules.
491    pub fn parse_media_rules<S>(input: S) -> Vec<MediaRule>
492    where
493        S: AsRef<str>,
494    {
495        let input: &str = input.as_ref();
496        let mut rules: Vec<MediaRule> = Vec::new();
497        let mut remaining: &str = input;
498        while !remaining.is_empty() {
499            if !remaining.starts_with(CSS_MEDIA_PREFIX) {
500                break;
501            }
502            let after_prefix: &str = remaining.strip_prefix(CSS_MEDIA_PREFIX).unwrap_or_default();
503            let query_end: Option<usize> = after_prefix.find(CSS_RULE_OPEN);
504            let Some(query_end_index) = query_end else {
505                break;
506            };
507            let query: &str = &after_prefix[..query_end_index];
508            let after_query: &str = after_prefix[query_end_index..]
509                .strip_prefix(CSS_RULE_OPEN)
510                .unwrap_or_default();
511            let mut depth: usize = 1;
512            let mut close_pos: usize = 0;
513            for (index, char_value) in after_query.char_indices() {
514                if char_value == '{' {
515                    depth += 1;
516                } else if char_value == '}' {
517                    depth -= 1;
518                    if depth == 0 {
519                        close_pos = index;
520                        break;
521                    }
522                }
523            }
524            if close_pos == 0 {
525                break;
526            }
527            let body: &str = &after_query[..close_pos];
528            let (style, pseudo_rules): (String, Vec<PseudoRule>) = Self::parse_media_body(body);
529            if !query.is_empty() && (!style.is_empty() || !pseudo_rules.is_empty()) {
530                rules.push(MediaRule::new(query.to_string(), style, pseudo_rules));
531            }
532            remaining = after_query[close_pos..]
533                .strip_prefix(CHAR_CSS_RULE_CLOSE)
534                .unwrap_or_default();
535        }
536        rules
537    }
538
539    /// Parses the body of a media rule, separating top-level style declarations
540    /// from nested pseudo-element blocks.
541    ///
542    /// # Arguments
543    ///
544    /// - `&str` - The media rule body content (between the outer braces).
545    ///
546    /// # Returns
547    ///
548    /// - `(String, Vec<PseudoRule>)` - A tuple of the style string and pseudo rules.
549    fn parse_media_body(body: &str) -> (String, Vec<PseudoRule>) {
550        let mut style_parts: String = String::new();
551        let mut pseudo_rules: Vec<PseudoRule> = Vec::new();
552        let mut remaining: &str = body;
553        while !remaining.is_empty() {
554            let brace_pos: Option<usize> = remaining.find('{');
555            match brace_pos {
556                Some(pos) => {
557                    let before_brace: &str = remaining[..pos].trim();
558                    if before_brace.starts_with("::") || before_brace.starts_with(':') {
559                        let selector: &str = before_brace;
560                        let after_brace: &str = &remaining[pos + 1..];
561                        let mut depth: usize = 1;
562                        let mut close_pos: usize = 0;
563                        for (index, char_value) in after_brace.char_indices() {
564                            if char_value == '{' {
565                                depth += 1;
566                            } else if char_value == '}' {
567                                depth -= 1;
568                                if depth == 0 {
569                                    close_pos = index;
570                                    break;
571                                }
572                            }
573                        }
574                        if close_pos > 0 {
575                            let inner_style: &str = after_brace[..close_pos].trim();
576                            if !selector.is_empty() && !inner_style.is_empty() {
577                                pseudo_rules.push(PseudoRule::new(
578                                    selector.to_string(),
579                                    inner_style.to_string(),
580                                ));
581                            }
582                            remaining = after_brace[close_pos + 1..].trim_start();
583                            continue;
584                        }
585                        break;
586                    } else {
587                        style_parts.push_str(before_brace);
588                        style_parts.push(' ');
589                        let after_brace: &str = &remaining[pos + 1..];
590                        let mut depth: usize = 1;
591                        let mut close_pos: usize = 0;
592                        for (index, char_value) in after_brace.char_indices() {
593                            if char_value == '{' {
594                                depth += 1;
595                            } else if char_value == '}' {
596                                depth -= 1;
597                                if depth == 0 {
598                                    close_pos = index;
599                                    break;
600                                }
601                            }
602                        }
603                        if close_pos > 0 {
604                            style_parts.push_str(after_brace[..close_pos].trim());
605                            style_parts.push(' ');
606                            remaining = after_brace[close_pos + 1..].trim_start();
607                            continue;
608                        }
609                        break;
610                    }
611                }
612                None => {
613                    style_parts.push_str(remaining.trim());
614                    break;
615                }
616            }
617        }
618        (style_parts.trim().to_string(), pseudo_rules)
619    }
620
621    /// Injects this class's styles into the DOM if not already present.
622    ///
623    /// Uses a global `HashSet` to track injected class names, avoiding the
624    /// expensive `existing_css.contains(css)` full-text search on every call.
625    /// Builds the class rule, pseudo-class rules, and media rules as CSS text,
626    /// then appends them directly to the `<style>` element via
627    /// `append_child` with a new text node — no read-modify-write of the
628    /// entire stylesheet content.
629    ///
630    /// # Panics
631    ///
632    /// Panics if `window()` or `document()` is unavailable on the current platform.
633    pub fn inject_style(&self) {
634        let class_name: &String = self.get_name();
635        if InjectedClassesCell::is_injected(class_name) {
636            return;
637        }
638        InjectedClassesCell::mark_injected(class_name);
639        let raw_name: String = self.get_name().clone();
640        let mut escaped_name: String = String::with_capacity(raw_name.len() * 2);
641        for ch in raw_name.chars() {
642            if ch.is_ascii_alphanumeric() || ch == CHAR_HYPHEN || ch == CHAR_UNDERSCORE {
643                escaped_name.push(ch);
644            } else {
645                escaped_name.push(CHAR_CSS_ESCAPE);
646                escaped_name.push(ch);
647            }
648        }
649        let mut css_text: String = format!(
650            "{CHAR_CSS_CLASS_PREFIX}{escaped_name}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
651            self.get_style()
652        );
653        for pseudo_rule in self.get_pseudo_rules() {
654            if !pseudo_rule.get_style().is_empty() {
655                css_text = format!(
656                    "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
657                    escaped_name,
658                    pseudo_rule.get_selector(),
659                    pseudo_rule.get_style()
660                );
661            }
662        }
663        for media_rule in self.get_media_rules() {
664            if !media_rule.get_query().is_empty() {
665                let mut media_body: String = format!(
666                    "{CHAR_CSS_CLASS_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
667                    escaped_name,
668                    media_rule.get_style()
669                );
670                for pseudo_rule in media_rule.get_pseudo_rules() {
671                    if !pseudo_rule.get_style().is_empty() {
672                        media_body = format!(
673                            "{media_body} {CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
674                            escaped_name,
675                            pseudo_rule.get_selector(),
676                            pseudo_rule.get_style()
677                        );
678                    }
679                }
680                css_text = format!(
681                    "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CSS_MEDIA_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
682                    media_rule.get_query(),
683                    media_body
684                );
685            }
686        }
687        Self::append_css(&css_text);
688    }
689
690    /// Appends CSS text directly to the shared `<style>` element.
691    ///
692    /// Creates a new text node and appends it as a child of the `<style>`
693    /// element, avoiding the read-modify-write pattern of reading the entire
694    /// `innerText`, concatenating, and setting it back.
695    ///
696    /// # Arguments
697    ///
698    /// - `&str` - The CSS text to append.
699    ///
700    /// The `cfg!` guard matches the one `Scheduler::current_time` already
701    /// uses: `window()` reads a wasm-only imported static, so on a non-wasm
702    /// target it panics with "cannot access imported statics on non-wasm
703    /// targets" rather than returning `None`. That panic short-circuits the
704    /// graceful `match window() { None => return }` below, and it fires from
705    /// `OnceLock<Css>::get_or_init` — the first time any `c_*()` class
706    /// function is called. Every styled view component calls one, so
707    /// without this guard none of them can be rendered in a native
708    /// `cargo test` process. On wasm the guard is a constant `true` and
709    /// this function behaves exactly as before.
710    fn append_css(css_text: &str) {
711        if !cfg!(target_arch = "wasm32") {
712            return;
713        }
714        let style_id: &str = EUV_CSS_INJECTED_ID;
715        let window_value: Window = match window() {
716            Some(window_instance) => window_instance,
717            None => return,
718        };
719        let document: Document = match window_value.document() {
720            Some(document_instance) => document_instance,
721            None => return,
722        };
723        let style_element: HtmlStyleElement = match document.get_element_by_id(style_id) {
724            Some(existing_element) => match existing_element.dyn_into::<HtmlStyleElement>() {
725                Ok(element) => element,
726                Err(_err) => return,
727            },
728            None => {
729                let created: Element = match document.create_element(STYLE_TAG) {
730                    Ok(element) => element,
731                    Err(_err) => return,
732                };
733                let style_element_from_id: HtmlStyleElement =
734                    match created.dyn_into::<HtmlStyleElement>() {
735                        Ok(element) => element,
736                        Err(_err) => return,
737                    };
738                style_element_from_id.set_id(style_id);
739                if let Some(head) = document.head() {
740                    let _: Result<Node, JsValue> = head.append_child(&style_element_from_id);
741                }
742                style_element_from_id
743            }
744        };
745        if !css_text.is_empty() {
746            let text_node: Text = document.create_text_node(css_text);
747            let _: Result<Node, JsValue> = style_element.append_child(&text_node);
748        }
749    }
750
751    /// Builds a CSS style string from an array of key-value pairs.
752    ///
753    /// This function is used by the `html!` macro to convert static `style:`
754    /// attributes into a CSS string without allocating intermediate objects.
755    ///
756    /// # Arguments
757    ///
758    /// - `&[(K, V)]` - An array of CSS property name-value pairs.
759    ///
760    /// # Returns
761    ///
762    /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
763    pub fn style_string<K, V>(props: &[(K, V)]) -> String
764    where
765        K: AsRef<str>,
766        V: AsRef<str>,
767    {
768        // Single pre-sized `String` instead of per-prop `format!` +
769        // intermediate `Vec<String>` + `join` (was N + 2 allocations).
770        let total_len: usize = props
771            .iter()
772            .map(|(key, value): &(K, V)| {
773                key.as_ref().len() + CSS_PROP_SEPARATOR.len() + value.as_ref().len() + 2
774            })
775            .sum();
776        let mut out: String = String::with_capacity(total_len);
777        for (index, (key, value)) in props.iter().enumerate() {
778            if index > 0 {
779                out.push_str(CHAR_SPACE);
780            }
781            out.push_str(key.as_ref());
782            out.push_str(CSS_PROP_SEPARATOR);
783            out.push_str(value.as_ref());
784            out.push(CHAR_CSS_DECL_TERMINATOR);
785        }
786        out
787    }
788
789    /// Builds a stable suffix for a class name from a dynamic parameter value.
790    ///
791    /// Used by the `class!` macro when a parameter is wrapped in `{}` in the
792    /// class body. Wrapping a parameter opts it into value-dependent class
793    /// names, so each distinct value can inject its own CSS rule.
794    ///
795    /// # Arguments
796    ///
797    /// - `&str` - The dynamic parameter value.
798    ///
799    /// # Returns
800    ///
801    /// - `String` - A stable hexadecimal suffix for the class name.
802    pub fn param_class_name(value: &str) -> String {
803        let mut hash: u64 = CLASS_PARAM_HASH_FNV_OFFSET;
804        for byte in value.as_bytes() {
805            hash ^= u64::from(*byte);
806            hash = hash.wrapping_mul(CLASS_PARAM_HASH_FNV_PRIME);
807        }
808        format!("{hash:x}")
809    }
810
811    /// Builds a CSS style string from owned key-value pairs.
812    ///
813    /// Used by the `html!` macro for reactive style attributes (with `if`
814    /// conditions) where values are computed at runtime.
815    ///
816    /// # Arguments
817    ///
818    /// - `&[(String, String)]` - An array of owned CSS property name-value pairs.
819    ///
820    /// # Returns
821    ///
822    /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
823    pub fn style_string_owned(props: &[(String, String)]) -> String {
824        // Single pre-sized `String` (same shape as `style_string`).
825        let total_len: usize = props
826            .iter()
827            .map(|(key, value): &(String, String)| {
828                key.len() + CSS_PROP_SEPARATOR.len() + value.len() + 2
829            })
830            .sum();
831        let mut out: String = String::with_capacity(total_len);
832        for (index, (key, value)) in props.iter().enumerate() {
833            if index > 0 {
834                out.push_str(CHAR_SPACE);
835            }
836            out.push_str(key);
837            out.push_str(CSS_PROP_SEPARATOR);
838            out.push_str(value);
839            out.push(CHAR_CSS_DECL_TERMINATOR);
840        }
841        out
842    }
843
844    /// Injects CSS text into the shared `<style>` element in the DOM.
845    ///
846    /// Delegates to [`Css::append_css`] for the actual DOM append.
847    /// Unlike the previous implementation, this does not read the existing
848    /// stylesheet content or perform a full-text `contains` search.
849    ///
850    /// # Arguments
851    ///
852    /// - `S` - The CSS text to inject (e.g., reset styles, keyframes, media queries).
853    ///
854    /// # Panics
855    ///
856    /// Panics if `window()` or `document()` is unavailable on the current platform.
857    pub fn inject_css<S>(css_text: S)
858    where
859        S: AsRef<str>,
860    {
861        let css_text: &str = css_text.as_ref();
862        Self::append_css(css_text);
863    }
864}
865
866/// Displays the CSS class name.
867///
868/// This enables `format!("{css}")` to produce the class name string,
869/// which is required for reactive `if` conditions in `class:` attributes.
870impl Display for Css {
871    /// Formats the CSS class as its name string.
872    ///
873    /// # Arguments
874    ///
875    /// - `&mut Formatter<'_>` - The formatter.
876    ///
877    /// # Returns
878    ///
879    /// - `fmt::Result` - The formatting result.
880    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
881        write!(formatter, "{}", self.get_name())
882    }
883}