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}