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