Skip to main content

euv_core/vdom/attribute/
impl.rs

1use crate::*;
2
3/// Implementation of attribute value factory methods for reactive and merged values.
4impl AttributeValue {
5    /// Creates a reactive attribute `Self` for conditional attribute values.
6    ///
7    /// This function replaces the inline `Signal::create(...)` + `subscribe_attr(...)`
8    /// boilerplate that was previously generated by the `html!` macro for every
9    /// attribute value containing an `if` condition.
10    ///
11    /// # Arguments
12    ///
13    /// - `Fn() -> String + 'static` - A closure that computes the current attribute value.
14    ///   Called on initial render and whenever any signal changes.
15    ///
16    /// # Returns
17    ///
18    /// - `Self` - A `Self::Signal` backed by a `Signal<String>`
19    ///   that reactively re-evaluates the attribute value on signal updates.
20    pub fn reactive<F>(compute: F) -> Self
21    where
22        F: Fn() -> String + 'static,
23    {
24        let attr_signal: Signal<String> = Signal::create(compute());
25        Self::subscribe_attr(attr_signal, compute);
26        Self::Signal(attr_signal)
27    }
28
29    /// Merges multiple class attribute values into a single `Self`.
30    ///
31    /// Each input value is adapted into a `Self` via `IntoReactiveValue`.
32    /// `Css` values are injected into the DOM and their names are collected.
33    /// All non-empty class names are joined with spaces into a final `Text` attribute.
34    /// If any value is signal-backed, the result becomes a reactive `Signal` attribute
35    /// that re-evaluates when any constituent signal changes.
36    ///
37    /// # Arguments
38    ///
39    /// - `&[Self]` - The class attribute values to merge.
40    ///
41    /// # Returns
42    ///
43    /// - `Self` - A merged attribute value containing space-separated class names.
44    pub fn merge_class(values: &[Self]) -> Self {
45        let has_signal: bool = values
46            .iter()
47            .any(|value: &Self| matches!(value, Self::Signal(_)));
48        if has_signal {
49            let owned_values: Vec<Self> = values.to_vec();
50            let compute: Box<dyn Fn() -> String> = Box::new(move || {
51                owned_values
52                    .iter()
53                    .filter_map(|value: &Self| match value {
54                        Self::Css(css) => {
55                            css.inject_style();
56                            Some(css.get_name().to_string())
57                        }
58                        Self::Text(text_value) => Some(text_value.clone()),
59                        Self::Signal(signal) => Some(signal.get()),
60                        _ => None,
61                    })
62                    .filter(|segment: &String| !segment.is_empty())
63                    .collect::<Vec<String>>()
64                    .join(&CHAR_SPACE.to_string())
65            });
66            let attr_signal: Signal<String> = Signal::create(compute());
67            Self::subscribe_attr(attr_signal, compute);
68            Self::Signal(attr_signal)
69        } else {
70            let result: String = values
71                .iter()
72                .filter_map(|value: &Self| match value {
73                    Self::Css(css) => {
74                        css.inject_style();
75                        Some(css.get_name().to_string())
76                    }
77                    Self::Text(text_value) => Some(text_value.clone()),
78                    _ => None,
79                })
80                .filter(|segment: &String| !segment.is_empty())
81                .collect::<Vec<String>>()
82                .join(&CHAR_SPACE.to_string());
83            Self::Text(result)
84        }
85    }
86
87    /// Merges multiple style attribute values into a single `Self`.
88    ///
89    /// Each input value is expected to be a style string (`Text`) or a reactive
90    /// `Signal<String>` producing a style string. All non-empty style strings are
91    /// joined with spaces into a final combined style attribute.
92    /// If any value is signal-backed, the result becomes a reactive `Signal` attribute.
93    ///
94    /// # Arguments
95    ///
96    /// - `&[Self]` - The style attribute values to merge.
97    ///
98    /// # Returns
99    ///
100    /// - `Self` - A merged attribute value containing the combined CSS style string.
101    pub fn merge_style(values: &[Self]) -> Self {
102        let has_signal: bool = values
103            .iter()
104            .any(|value: &Self| matches!(value, Self::Signal(_)));
105        if has_signal {
106            let owned_values: Vec<Self> = values.to_vec();
107            let compute: Box<dyn Fn() -> String> = Box::new(move || {
108                owned_values
109                    .iter()
110                    .filter_map(|value: &Self| match value {
111                        Self::Text(text_value) => Some(text_value.clone()),
112                        Self::Signal(signal) => Some(signal.get()),
113                        _ => None,
114                    })
115                    .filter(|segment: &String| !segment.is_empty())
116                    .collect::<Vec<String>>()
117                    .join(&CHAR_SPACE.to_string())
118            });
119            let attr_signal: Signal<String> = Signal::create(compute());
120            Self::subscribe_attr(attr_signal, compute);
121            Self::Signal(attr_signal)
122        } else {
123            let result: String = values
124                .iter()
125                .filter_map(|value: &Self| match value {
126                    Self::Text(text_value) => Some(text_value.clone()),
127                    _ => None,
128                })
129                .filter(|segment: &String| !segment.is_empty())
130                .collect::<Vec<String>>()
131                .join(&CHAR_SPACE.to_string());
132            Self::Text(result)
133        }
134    }
135
136    /// Subscribes an attribute signal to the global signal update dispatch cycle.
137    ///
138    /// Creates a callback that re-computes the attribute value and sets
139    /// it on the signal whenever a signal update cycle runs. The callback
140    /// is registered in the signal update registry using the signal's
141    /// inner address as the key.
142    ///
143    /// # Arguments
144    ///
145    /// - `Signal<String>` - The attribute signal to subscribe.
146    /// - `Fn() -> String + 'static` - A closure that computes the current attribute value string.
147    fn subscribe_attr<F>(attr_signal: Signal<String>, compute: F)
148    where
149        F: Fn() -> String + 'static,
150    {
151        Registry::register_attr_listener(
152            attr_signal.get_inner(),
153            Box::new(move || {
154                attr_signal.set(compute());
155            }),
156        );
157    }
158
159    /// Converts a bool signal into a reactive `Signal<String>` attribute value.
160    ///
161    /// Creates a `Signal<String>` initialized with the bool's string
162    /// representation, then subscribes to the source signal so that
163    /// whenever the bool changes, the string signal is updated accordingly.
164    ///
165    /// # Arguments
166    ///
167    /// - `Signal<bool>` - The source boolean signal.
168    ///
169    /// # Returns
170    ///
171    /// - `AttributeValue` - An `AttributeValue::Signal` wrapping the derived string signal.
172    pub(crate) fn bool_to_attr(source: Signal<bool>) -> AttributeValue {
173        let string_signal: Signal<String> = Signal::create(source.get().to_string());
174        let string_signal_clone: Signal<String> = string_signal;
175        let source_for_sub: Signal<bool> = source;
176        source_for_sub.subscribe(move || {
177            string_signal_clone.set(source_for_sub.get().to_string());
178        });
179        AttributeValue::Signal(string_signal)
180    }
181}
182
183/// Visual equality comparison for attribute values.
184///
185/// Compares values by their visual output rather than identity. `Signal`
186/// values are compared by their current resolved string; when both signals
187/// share the same inner pointer, they are always considered **unequal**
188/// because the signal may have mutated between VDOM snapshots and `.get()`
189/// would return the same current value for both, masking the change.
190/// `Event` values are always considered equal (re-binding is handled by the
191/// handler registry), and `Css` values are compared by class name.
192impl PartialEq for AttributeValue {
193    /// Compares two attribute values for visual equality.
194    ///
195    /// # Arguments
196    ///
197    /// - `&Self` - The first attribute value.
198    /// - `&Self` - The second attribute value.
199    ///
200    /// # Returns
201    ///
202    /// - `bool` - `true` if the values are visually equal.
203    fn eq(&self, other: &Self) -> bool {
204        match (self, other) {
205            (Self::Text(old_value), Self::Text(new_value)) => old_value == new_value,
206            (Self::Signal(old_signal), Self::Signal(new_signal)) => {
207                if old_signal.get_inner() == new_signal.get_inner() {
208                    return false;
209                }
210                old_signal.get() == new_signal.get()
211            }
212            (Self::Signal(old_signal), Self::Text(new_value)) => old_signal.get() == *new_value,
213            (Self::Text(old_value), Self::Signal(new_signal)) => *old_value == new_signal.get(),
214            (Self::Event(_), Self::Event(_)) => true,
215            (Self::Css(old_class), Self::Css(new_class)) => {
216                old_class.get_name() == new_class.get_name()
217            }
218            (Self::Dynamic(old_dynamic), Self::Dynamic(new_dynamic)) => old_dynamic == new_dynamic,
219            _ => false,
220        }
221    }
222}
223
224/// Visual equality comparison for attribute entries.
225///
226/// Two attribute entries are equal when their names match and their values
227/// are visually equal as defined by `AttributeValue::eq`.
228impl PartialEq for AttributeEntry {
229    /// Compares two attribute entries for visual equality.
230    ///
231    /// # Arguments
232    ///
233    /// - `&Self` - The first attribute entry.
234    /// - `&Self` - The second attribute entry.
235    ///
236    /// # Returns
237    ///
238    /// - `bool` - `true` if both names and values match.
239    fn eq(&self, other: &Self) -> bool {
240        self.get_name() == other.get_name() && self.get_value() == other.get_value()
241    }
242}
243
244/// Visual equality comparison for CSS classes.
245///
246/// Two CSS classes are considered equal when their class names match,
247/// since the name uniquely identifies the visual style rule.
248impl PartialEq for Css {
249    /// Compares two CSS classes by name.
250    ///
251    /// # Arguments
252    ///
253    /// - `&Self` - The first CSS class.
254    /// - `&Self` - The second CSS class.
255    ///
256    /// # Returns
257    ///
258    /// - `bool` - `true` if the class names match.
259    fn eq(&self, other: &Self) -> bool {
260        self.get_name() == other.get_name()
261    }
262}
263
264/// Implementation of Css construction and style injection.
265impl Css {
266    /// Parses pseudo-class/pseudo-element rules from a compact serialization string.
267    ///
268    /// The serialization format is: `:selector { key: value; key: value; }:another { ... }`
269    /// This is used by the `class!` macro for fully static class definitions
270    /// where pseudo rules can be computed at compile time.
271    ///
272    /// # Arguments
273    ///
274    /// - `I: AsRef<str>` - The serialized pseudo rules string.
275    ///
276    /// # Returns
277    ///
278    /// - `Vec<PseudoRule>` - The parsed pseudo rules.
279    pub fn parse_pseudo_rules<I>(input: I) -> Vec<PseudoRule>
280    where
281        I: AsRef<str>,
282    {
283        let mut remaining: &str = input.as_ref();
284        let mut rules: Vec<PseudoRule> = Vec::new();
285        while !remaining.is_empty() {
286            let selector_end: Option<usize> = remaining.find(CSS_RULE_OPEN);
287            let Some(selector_end_index) = selector_end else {
288                break;
289            };
290            let selector: &str = &remaining[..selector_end_index];
291            let after_selector: &str = remaining[selector_end_index..]
292                .strip_prefix(CSS_RULE_OPEN)
293                .unwrap_or_default();
294            let style_end: Option<usize> = after_selector.find(CHAR_CSS_RULE_CLOSE);
295            let Some(style_end_index) = style_end else {
296                break;
297            };
298            let style: &str = &after_selector[..style_end_index];
299            if !selector.is_empty() && !style.is_empty() {
300                rules.push(PseudoRule::new(selector.to_string(), style.to_string()));
301            }
302            remaining = after_selector[style_end_index..]
303                .strip_prefix(CHAR_CSS_RULE_CLOSE)
304                .unwrap_or_default();
305        }
306        rules
307    }
308
309    /// Parses media query rules from a compact serialization string.
310    ///
311    /// The serialization format is:
312    /// `@media query { key: value; ::selector { key: value; } }@media query2 { ... }`
313    /// This is used by the `class!` macro for fully static class definitions
314    /// where media rules can be computed at compile time.
315    /// Supports nested pseudo-element blocks inside media query blocks.
316    ///
317    /// # Arguments
318    ///
319    /// - `S: AsRef<str>` - The serialized media rules string.
320    ///
321    /// # Returns
322    ///
323    /// - `Vec<MediaRule>` - The parsed media rules.
324    pub fn parse_media_rules<S>(input: S) -> Vec<MediaRule>
325    where
326        S: AsRef<str>,
327    {
328        let input: &str = input.as_ref();
329        let mut rules: Vec<MediaRule> = Vec::new();
330        let mut remaining: &str = input;
331        while !remaining.is_empty() {
332            if !remaining.starts_with(CSS_MEDIA_PREFIX) {
333                break;
334            }
335            let after_prefix: &str = remaining.strip_prefix(CSS_MEDIA_PREFIX).unwrap_or_default();
336            let query_end: Option<usize> = after_prefix.find(CSS_RULE_OPEN);
337            let Some(query_end_index) = query_end else {
338                break;
339            };
340            let query: &str = &after_prefix[..query_end_index];
341            let after_query: &str = after_prefix[query_end_index..]
342                .strip_prefix(CSS_RULE_OPEN)
343                .unwrap_or_default();
344            let mut depth: usize = 1;
345            let mut close_pos: usize = 0;
346            for (index, char_value) in after_query.char_indices() {
347                if char_value == '{' {
348                    depth += 1;
349                } else if char_value == '}' {
350                    depth -= 1;
351                    if depth == 0 {
352                        close_pos = index;
353                        break;
354                    }
355                }
356            }
357            if close_pos == 0 {
358                break;
359            }
360            let body: &str = &after_query[..close_pos];
361            let (style, pseudo_rules): (String, Vec<PseudoRule>) = Self::parse_media_body(body);
362            if !query.is_empty() && (!style.is_empty() || !pseudo_rules.is_empty()) {
363                rules.push(MediaRule::new(query.to_string(), style, pseudo_rules));
364            }
365            remaining = after_query[close_pos..]
366                .strip_prefix(CHAR_CSS_RULE_CLOSE)
367                .unwrap_or_default();
368        }
369        rules
370    }
371
372    /// Parses the body of a media rule, separating top-level style declarations
373    /// from nested pseudo-element blocks.
374    ///
375    /// # Arguments
376    ///
377    /// - `&str` - The media rule body content (between the outer braces).
378    ///
379    /// # Returns
380    ///
381    /// - `(String, Vec<PseudoRule>)` - A tuple of the style string and pseudo rules.
382    fn parse_media_body(body: &str) -> (String, Vec<PseudoRule>) {
383        let mut style_parts: String = String::new();
384        let mut pseudo_rules: Vec<PseudoRule> = Vec::new();
385        let mut remaining: &str = body;
386        while !remaining.is_empty() {
387            let brace_pos: Option<usize> = remaining.find('{');
388            match brace_pos {
389                Some(pos) => {
390                    let before_brace: &str = remaining[..pos].trim();
391                    if before_brace.starts_with("::") || before_brace.starts_with(':') {
392                        let selector: &str = before_brace;
393                        let after_brace: &str = &remaining[pos + 1..];
394                        let mut depth: usize = 1;
395                        let mut close_pos: usize = 0;
396                        for (index, char_value) in after_brace.char_indices() {
397                            if char_value == '{' {
398                                depth += 1;
399                            } else if char_value == '}' {
400                                depth -= 1;
401                                if depth == 0 {
402                                    close_pos = index;
403                                    break;
404                                }
405                            }
406                        }
407                        if close_pos > 0 {
408                            let inner_style: &str = after_brace[..close_pos].trim();
409                            if !selector.is_empty() && !inner_style.is_empty() {
410                                pseudo_rules.push(PseudoRule::new(
411                                    selector.to_string(),
412                                    inner_style.to_string(),
413                                ));
414                            }
415                            remaining = after_brace[close_pos + 1..].trim_start();
416                            continue;
417                        }
418                        break;
419                    } else {
420                        style_parts.push_str(before_brace);
421                        style_parts.push(' ');
422                        let after_brace: &str = &remaining[pos + 1..];
423                        let mut depth: usize = 1;
424                        let mut close_pos: usize = 0;
425                        for (index, char_value) in after_brace.char_indices() {
426                            if char_value == '{' {
427                                depth += 1;
428                            } else if char_value == '}' {
429                                depth -= 1;
430                                if depth == 0 {
431                                    close_pos = index;
432                                    break;
433                                }
434                            }
435                        }
436                        if close_pos > 0 {
437                            style_parts.push_str(after_brace[..close_pos].trim());
438                            style_parts.push(' ');
439                            remaining = after_brace[close_pos + 1..].trim_start();
440                            continue;
441                        }
442                        break;
443                    }
444                }
445                None => {
446                    style_parts.push_str(remaining.trim());
447                    break;
448                }
449            }
450        }
451        (style_parts.trim().to_string(), pseudo_rules)
452    }
453
454    /// Injects this class's styles into the DOM if not already present.
455    ///
456    /// Uses a global `HashSet` to track injected class names, avoiding the
457    /// expensive `existing_css.contains(css)` full-text search on every call.
458    /// Builds the class rule, pseudo-class rules, and media rules as CSS text,
459    /// then appends them directly to the `<style>` element via
460    /// `append_child` with a new text node — no read-modify-write of the
461    /// entire stylesheet content.
462    ///
463    /// # Panics
464    ///
465    /// Panics if `window()` or `document()` is unavailable on the current platform.
466    pub fn inject_style(&self) {
467        let raw_name: String = self.get_name().clone();
468        let mut escaped_name: String = String::with_capacity(raw_name.len() * 2);
469        for ch in raw_name.chars() {
470            if ch.is_ascii_alphanumeric() || ch == CHAR_HYPHEN || ch == CHAR_UNDERSCORE {
471                escaped_name.push(ch);
472            } else {
473                escaped_name.push(CHAR_CSS_ESCAPE);
474                escaped_name.push(ch);
475            }
476        }
477        let mut css_text: String = format!(
478            "{CHAR_CSS_CLASS_PREFIX}{escaped_name}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
479            self.get_style()
480        );
481        for pseudo_rule in self.get_pseudo_rules() {
482            if !pseudo_rule.get_style().is_empty() {
483                css_text = format!(
484                    "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
485                    escaped_name,
486                    pseudo_rule.get_selector(),
487                    pseudo_rule.get_style()
488                );
489            }
490        }
491        for media_rule in self.get_media_rules() {
492            if !media_rule.get_query().is_empty() {
493                let mut media_body: String = format!(
494                    "{CHAR_CSS_CLASS_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
495                    escaped_name,
496                    media_rule.get_style()
497                );
498                for pseudo_rule in media_rule.get_pseudo_rules() {
499                    if !pseudo_rule.get_style().is_empty() {
500                        media_body = format!(
501                            "{media_body} {CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
502                            escaped_name,
503                            pseudo_rule.get_selector(),
504                            pseudo_rule.get_style()
505                        );
506                    }
507                }
508                css_text = format!(
509                    "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CSS_MEDIA_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
510                    media_rule.get_query(),
511                    media_body
512                );
513            }
514        }
515        Self::append_css(&css_text);
516    }
517
518    /// Appends CSS text directly to the shared `<style>` element.
519    ///
520    /// Creates a new text node and appends it as a child of the `<style>`
521    /// element, avoiding the read-modify-write pattern of reading the entire
522    /// `innerText`, concatenating, and setting it back.
523    ///
524    /// # Arguments
525    ///
526    /// - `&str` - The CSS text to append.
527    ///
528    fn append_css(css_text: &str) {
529        let style_id: &str = EUV_CSS_INJECTED_ID;
530        let window_value: Window = match window() {
531            Some(window_instance) => window_instance,
532            None => return,
533        };
534        let document: Document = match window_value.document() {
535            Some(document_instance) => document_instance,
536            None => return,
537        };
538        let style_element: HtmlStyleElement = match document.get_element_by_id(style_id) {
539            Some(existing_element) => match existing_element.dyn_into::<HtmlStyleElement>() {
540                Ok(element) => element,
541                Err(_err) => return,
542            },
543            None => {
544                let created: Element = match document.create_element(STYLE_TAG) {
545                    Ok(element) => element,
546                    Err(_err) => return,
547                };
548                let style_element_from_id: HtmlStyleElement =
549                    match created.dyn_into::<HtmlStyleElement>() {
550                        Ok(element) => element,
551                        Err(_err) => return,
552                    };
553                style_element_from_id.set_id(style_id);
554                if let Some(head) = document.head() {
555                    let _ = head.append_child(&style_element_from_id);
556                }
557                style_element_from_id
558            }
559        };
560        if !css_text.is_empty() {
561            let text_node: Text = document.create_text_node(css_text);
562            let _ = style_element.append_child(&text_node);
563        }
564    }
565
566    /// Builds a CSS style string from an array of key-value pairs.
567    ///
568    /// This function is used by the `html!` macro to convert static `style:`
569    /// attributes into a CSS string without allocating intermediate objects.
570    ///
571    /// # Arguments
572    ///
573    /// - `S: AsRef<str>` - An array of CSS property name-value pairs.
574    ///
575    /// # Returns
576    ///
577    /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
578    pub fn style_string<K, V>(props: &[(K, V)]) -> String
579    where
580        K: AsRef<str>,
581        V: AsRef<str>,
582    {
583        props
584            .iter()
585            .map(|(key, value): &(K, V)| {
586                format!(
587                    "{}{CSS_PROP_SEPARATOR}{}{CHAR_CSS_DECL_TERMINATOR}",
588                    key.as_ref(),
589                    value.as_ref()
590                )
591            })
592            .collect::<Vec<String>>()
593            .join(&CHAR_SPACE.to_string())
594    }
595
596    /// Builds a CSS style string from owned key-value pairs.
597    ///
598    /// Used by the `html!` macro for reactive style attributes (with `if`
599    /// conditions) where values are computed at runtime.
600    ///
601    /// # Arguments
602    ///
603    /// - `&[(String, String)]` - An array of owned CSS property name-value pairs.
604    ///
605    /// # Returns
606    ///
607    /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
608    pub fn style_string_owned(props: &[(String, String)]) -> String {
609        props
610            .iter()
611            .map(|(key, value): &(String, String)| {
612                format!("{key}{CSS_PROP_SEPARATOR}{value}{CHAR_CSS_DECL_TERMINATOR}")
613            })
614            .collect::<Vec<String>>()
615            .join(&CHAR_SPACE.to_string())
616    }
617
618    /// Injects CSS text into the shared `<style>` element in the DOM.
619    ///
620    /// Delegates to [`Css::append_css`] for the actual DOM append.
621    /// Unlike the previous implementation, this does not read the existing
622    /// stylesheet content or perform a full-text `contains` search.
623    ///
624    /// # Arguments
625    ///
626    /// - `S: AsRef<str>` - The CSS text to inject (e.g., reset styles, keyframes, media queries).
627    ///
628    /// # Panics
629    ///
630    /// Panics if `window()` or `document()` is unavailable on the current platform.
631    pub fn inject_css<S>(css_text: S)
632    where
633        S: AsRef<str>,
634    {
635        let css_text: &str = css_text.as_ref();
636        Self::append_css(css_text);
637    }
638}
639
640/// Displays the CSS class name.
641///
642/// This enables `format!("{}", css)` to produce the class name string,
643/// which is required for reactive `if` conditions in `class:` attributes.
644impl Display for Css {
645    /// Formats the CSS class as its name string.
646    ///
647    /// # Arguments
648    ///
649    /// - `&mut Formatter` - The formatter.
650    ///
651    /// # Returns
652    ///
653    /// - `fmt::Result` - The formatting result.
654    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
655        write!(formatter, "{}", self.get_name())
656    }
657}