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