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