euv_core/vdom/attribute/impl.rs
1use super::*;
2
3/// Implementation of the public read side of an attribute entry.
4impl AttributeEntry {
5 /// Returns the CSS class names this attribute carries.
6 ///
7 /// The `class` attribute is the one place where a component's
8 /// styling decisions become observable, so it needs a public read
9 /// side: a component library's tests have to be able to assert
10 /// that a state actually landed on the right class, and an external
11 /// crate cannot read the raw attribute value.
12 ///
13 /// Every shape the attribute can take is reported:
14 /// `Css` and `CssRef` contribute the class name they carry, `Text`
15 /// and `StaticText` contribute their string split on whitespace,
16 /// a `Signal` contributes the class list it currently holds — that
17 /// is the shape a reactive `class:` attribute takes — and the
18 /// remaining shapes, an event handler, contribute nothing, because
19 /// neither is a class list.
20 ///
21 /// # Returns
22 ///
23 /// - `Vec<String>` - The class names, in the order they appear.
24 pub fn class_names(&self) -> Vec<String> {
25 match self.get_value() {
26 AttributeValue::Css(css) => vec![css.get_name().clone()],
27 AttributeValue::CssRef(css) => vec![css.get_name().clone()],
28 AttributeValue::Text(text) => split_class_names(text),
29 AttributeValue::StaticText(text) => split_class_names(text),
30 AttributeValue::Signal(signal) => signal.with(|text: &String| split_class_names(text)),
31 _ => Vec::new(),
32 }
33 }
34
35 /// Returns the attribute's value as text, when it carries one.
36 ///
37 /// A `Text` or `StaticText` attribute is its own value; a `Css` or
38 /// `CssRef` attribute contributes its class name, because that is
39 /// the text the browser ends up showing for it. The remaining
40 /// shapes — a reactive signal, an event handler — carry no text.
41 ///
42 /// # Returns
43 ///
44 /// - `Option<String>` - The value, when the attribute has one.
45 pub fn text_value(&self) -> Option<String> {
46 match self.get_value() {
47 AttributeValue::Text(text) => Some(text.clone()),
48 AttributeValue::StaticText(text) => Some((*text).to_string()),
49 AttributeValue::Css(css) => Some(css.get_name().clone()),
50 AttributeValue::CssRef(css) => Some(css.get_name().clone()),
51 _ => None,
52 }
53 }
54}
55
56/// Implementation of injected class tracking for CSS deduplication.
57impl InjectedClassesCell {
58 /// Returns `true` if the given class name has already been injected into
59 /// the DOM.
60 ///
61 /// # Arguments
62 ///
63 /// - `&str` - The CSS class name to check.
64 ///
65 /// # Returns
66 ///
67 /// - `bool` - Whether the class name has been injected.
68 pub(crate) fn is_injected(class_name: &str) -> bool {
69 INJECTED_CLASSES
70 .read()
71 .map(|classes: RwLockReadGuard<'_, HashSet<String>>| classes.contains(class_name))
72 .unwrap_or(false)
73 }
74
75 /// Marks a class name as injected so future calls to `is_injected`
76 /// return `true`.
77 ///
78 /// A poisoned lock (which only happens if another thread panicked while
79 /// holding the write guard) is recovered from rather than propagated:
80 /// the set is deduplication bookkeeping, so losing a lock must never
81 /// take down the render path.
82 ///
83 /// # Arguments
84 ///
85 /// - `&str` - The CSS class name to mark as injected.
86 pub(crate) fn mark_injected(class_name: &str) {
87 let write_result: Result<
88 InjectedClassesWriteGuard<'_>,
89 PoisonError<InjectedClassesWriteGuard<'_>>,
90 > = INJECTED_CLASSES.write();
91 let Ok(mut classes) = write_result else {
92 return;
93 };
94 classes.insert(class_name.to_string());
95 }
96}
97
98/// Implementation of attribute value factory methods for reactive and merged values.
99impl AttributeValue {
100 /// Creates a reactive attribute `Self` for conditional attribute values.
101 ///
102 /// This function replaces the inline `Signal::create(...)` boilerplate
103 /// that was previously generated by the `html!` macro for every
104 /// attribute value containing an `if` condition.
105 ///
106 /// The returned `Self::Text` is a snapshot computed on each render of
107 /// the enclosing dynamic node: the signals read inside
108 /// `compute` during that render register the dynamic node as their
109 /// dependent, so any source change re-renders the node and rebuilds
110 /// this attribute with the new value. No signal slot or registry
111 /// listener is allocated for the snapshot. Direct `Self::Signal`
112 /// bindings retain their independent DOM subscription behavior.
113 ///
114 /// # Arguments
115 ///
116 /// - `F` - A closure that computes the current attribute value.
117 /// Called once per render of the enclosing dynamic node.
118 ///
119 /// # Returns
120 ///
121 /// - `Self` - A `Self::Text` carrying the freshly computed value.
122 pub fn reactive<F>(compute: F) -> Self
123 where
124 F: Fn() -> String + 'static,
125 {
126 Self::Text(compute())
127 }
128
129 /// Merges multiple class attribute values into a single `Self`.
130 ///
131 /// Each input value is adapted into a `Self` via `IntoReactiveValue`.
132 /// `Css` values are injected into the DOM and their names are collected.
133 /// All non-empty class names are joined with spaces into a final `Text` attribute.
134 /// Signal inputs are read without allocating a new signal for the
135 /// merged snapshot. The enclosing dynamic node re-renders on any
136 /// constituent signal change (see
137 /// [`AttributeValue::reactive`]).
138 ///
139 /// # Arguments
140 ///
141 /// - `&[Self]` - The class attribute values to merge.
142 ///
143 /// # Returns
144 ///
145 /// - `Self` - A merged attribute value containing space-separated class names.
146 pub fn merge_class(values: &[Self]) -> Self {
147 Self::Text(Self::join_class_segments(values))
148 }
149
150 /// Joins class attribute values into a single space-separated string.
151 ///
152 /// Appends borrowed segments directly to the result. Signal snapshots
153 /// stay borrowed through `with`, avoiding an owned string clone for
154 /// every reactive segment on every render.
155 ///
156 /// OPT-11: both `Css` and `CssRef` arms inject the style on first
157 /// reference. Without the `CssRef` arm the ref would fall through
158 /// to `_ => None` and silently drop out of the merged class list —
159 /// the regression that wiped every multi-class CssRef entry (e.g.
160 /// the `c_binding_slider` class next to a parameterized
161 /// `c_slider_value("30%")` on the same `<input>`).
162 ///
163 /// # Arguments
164 ///
165 /// - `&[Self]` - The class attribute values to merge, in order.
166 ///
167 /// # Returns
168 ///
169 /// - `String` - The merged class list, segments separated by a
170 /// single space; empty when no segment survived filtering.
171 fn join_class_segments(values: &[Self]) -> String {
172 let mut joined: String = String::with_capacity(Self::segments_capacity(values, true));
173 for value in values {
174 match value {
175 Self::Css(css) => {
176 css.inject_style();
177 Self::append_segment(&mut joined, css.get_name());
178 }
179 Self::CssRef(css) => {
180 css.inject_style();
181 Self::append_segment(&mut joined, css.get_name());
182 }
183 Self::Text(text_value) => {
184 Self::append_segment(&mut joined, text_value);
185 }
186 Self::Signal(signal) => {
187 signal.with(|text: &String| Self::append_segment(&mut joined, text));
188 }
189 _ => {}
190 }
191 }
192 joined
193 }
194
195 /// Computes the output byte capacity without copying signal payloads.
196 ///
197 /// # Arguments
198 ///
199 /// - `&[Self]` - Input segments whose supported nonempty values are counted.
200 /// - `bool` - Whether CSS class references are accepted by the merge.
201 ///
202 /// # Returns
203 ///
204 /// - `usize` - Required bytes including separators between nonempty segments.
205 fn segments_capacity(values: &[Self], classes: bool) -> usize {
206 let mut capacity: usize = 0;
207 for value in values {
208 let size: usize = match value {
209 Self::Css(css) if classes => css.get_name().len(),
210 Self::CssRef(css) if classes => css.get_name().len(),
211 Self::Text(text) => text.len(),
212 Self::Signal(signal) => signal.with(String::len),
213 _ => 0,
214 };
215 if size > 0 {
216 if capacity > 0 {
217 capacity += CHAR_SPACE.len();
218 }
219 capacity += size;
220 }
221 }
222 capacity
223 }
224
225 /// Appends a nonempty segment, separating it from preceding segments.
226 ///
227 /// # Arguments
228 ///
229 /// - `&mut String` - The destination string.
230 /// - `&str` - The borrowed segment to append.
231 fn append_segment(joined: &mut String, segment: &str) {
232 if segment.is_empty() {
233 return;
234 }
235 if !joined.is_empty() {
236 joined.push_str(CHAR_SPACE);
237 }
238 joined.push_str(segment);
239 }
240
241 /// Merges multiple style attribute values into a single `Self`.
242 ///
243 /// Each input value is expected to be a style string (`Text`) or a reactive
244 /// `Signal<String>` producing a style string. All non-empty style strings are
245 /// joined with spaces into a final combined style attribute.
246 /// Signal inputs are read without allocating a new signal for the
247 /// merged snapshot. The enclosing dynamic node re-renders on any
248 /// constituent signal change (see
249 /// [`AttributeValue::reactive`]).
250 ///
251 /// # Arguments
252 ///
253 /// - `&[Self]` - The style attribute values to merge.
254 ///
255 /// # Returns
256 ///
257 /// - `Self` - A merged attribute value containing the combined CSS style string.
258 pub fn merge_style(values: &[Self]) -> Self {
259 Self::Text(Self::join_style_segments(values))
260 }
261
262 /// Joins style attribute values into a single space-separated string.
263 ///
264 /// Borrows signal values without allocating intermediate snapshots,
265 /// using the same separator and empty-segment rules as class merging.
266 ///
267 /// # Arguments
268 ///
269 /// - `&[Self]` - The style attribute values to merge, in order.
270 ///
271 /// # Returns
272 ///
273 /// - `String` - The merged CSS declarations, segments separated by a
274 /// single space; empty when no segment carried a declaration.
275 fn join_style_segments(values: &[Self]) -> String {
276 let mut joined: String = String::with_capacity(Self::segments_capacity(values, false));
277 for value in values {
278 match value {
279 Self::Text(text_value) => {
280 Self::append_segment(&mut joined, text_value);
281 }
282 Self::Signal(signal) => {
283 signal.with(|text: &String| Self::append_segment(&mut joined, text));
284 }
285 _ => {}
286 }
287 }
288 joined
289 }
290
291 /// Converts a bool signal into a reactive boolean attribute value.
292 ///
293 /// Produces `AttributeValue::BoolSignal` directly: the renderer writes
294 /// `"true"` / `"false"` and subscribes the source signal to the element
295 /// with no intermediate mapping signal (the previous `Signal<String>`
296 /// bridge) and no per-render subscription.
297 ///
298 /// # Arguments
299 ///
300 /// - `Signal<bool>` - The source boolean signal.
301 ///
302 /// # Returns
303 ///
304 /// - `AttributeValue` - A `BoolSignal` wrapping the source signal.
305 pub(crate) fn bool_to_attr(source: Signal<bool>) -> AttributeValue {
306 AttributeValue::BoolSignal(source)
307 }
308}
309
310/// Visual equality comparison for attribute values.
311///
312/// Compares values by their visual output rather than identity. `Signal`
313/// values are compared by their current resolved string (borrow-compared
314/// via `Signal::with`, no `String` clones); when both signals share the
315/// same slab slot, they are always considered **unequal** — the patch walk
316/// recognises that case separately and skips the DOM write because the
317/// mount-time signal binding already owns the attribute's DOM value (see
318/// `patch_attributes`), while the unequal verdict keeps `visual_eq` from
319/// masking a value change between VDOM snapshots.
320/// `Event` values are always considered equal (re-binding is handled by the
321/// handler registry), and `Css` values are compared by class name.
322impl PartialEq for AttributeValue {
323 /// Compares two attribute values for visual equality.
324 ///
325 /// # Arguments
326 ///
327 /// - `&Self` - The first attribute value.
328 /// - `&Self` - The second attribute value.
329 ///
330 /// # Returns
331 ///
332 /// - `bool` - `true` if the values are visually equal.
333 fn eq(&self, other: &Self) -> bool {
334 match (self, other) {
335 (Self::Text(old_value), Self::Text(new_value)) => old_value == new_value,
336 (Self::StaticText(old_value), Self::StaticText(new_value)) => old_value == new_value,
337 (Self::Text(old_value), Self::StaticText(new_value)) => old_value == new_value,
338 (Self::StaticText(old_value), Self::Text(new_value)) => old_value == new_value,
339 (Self::Signal(old_signal), Self::Signal(new_signal)) => {
340 if old_signal.get_inner() == new_signal.get_inner() {
341 return false;
342 }
343 old_signal.with(|old_value: &String| {
344 new_signal.with(|new_value: &String| old_value == new_value)
345 })
346 }
347 (Self::Signal(old_signal), Self::Text(new_value)) => {
348 old_signal.with(|old_value: &String| old_value == new_value)
349 }
350 (Self::Text(old_value), Self::Signal(new_signal)) => {
351 new_signal.with(|new_value: &String| old_value == new_value)
352 }
353 (Self::BoolSignal(old_signal), Self::BoolSignal(new_signal)) => {
354 if old_signal.get_inner() == new_signal.get_inner() {
355 return false;
356 }
357 old_signal.get() == new_signal.get()
358 }
359 (Self::Event(_), Self::Event(_)) => true,
360 (Self::Css(old_class), Self::Css(new_class)) => {
361 old_class.get_name() == new_class.get_name()
362 }
363 (Self::CssRef(old_class), Self::CssRef(new_class)) => {
364 old_class.get_name() == new_class.get_name()
365 }
366 (Self::CssRef(old_class), Self::Css(new_class)) => {
367 old_class.get_name() == new_class.get_name()
368 }
369 (Self::Css(old_class), Self::CssRef(new_class)) => {
370 old_class.get_name() == new_class.get_name()
371 }
372 (Self::Dynamic(old_dynamic), Self::Dynamic(new_dynamic)) => old_dynamic == new_dynamic,
373 _ => false,
374 }
375 }
376}
377
378/// Visual equality comparison for attribute entries.
379///
380/// Two attribute entries are equal when their names match and their values
381/// are visually equal as defined by `AttributeValue::eq`.
382impl PartialEq for AttributeEntry {
383 /// Compares two attribute entries for visual equality.
384 ///
385 /// # Arguments
386 ///
387 /// - `&Self` - The first attribute entry.
388 /// - `&Self` - The second attribute entry.
389 ///
390 /// # Returns
391 ///
392 /// - `bool` - `true` if both names and values match.
393 fn eq(&self, other: &Self) -> bool {
394 self.get_name() == other.get_name() && self.get_value() == other.get_value()
395 }
396}
397
398/// Visual equality comparison for CSS classes.
399///
400/// Two CSS classes are considered equal when their class names match,
401/// since the name uniquely identifies the visual style rule.
402impl PartialEq for Css {
403 /// Compares two CSS classes by name.
404 ///
405 /// # Arguments
406 ///
407 /// - `&Self` - The first CSS class.
408 /// - `&Self` - The second CSS class.
409 ///
410 /// # Returns
411 ///
412 /// - `bool` - `true` if the class names match.
413 fn eq(&self, other: &Self) -> bool {
414 self.get_name() == other.get_name()
415 }
416}
417
418/// Implementation of Css construction and style injection.
419impl Css {
420 /// Parses pseudo-class/pseudo-element rules from a compact serialization string.
421 ///
422 /// The serialization format is: `:selector { key: value; key: value; }:another { ... }`
423 /// This is used by the `class!` macro for fully static class definitions
424 /// where pseudo rules can be computed at compile time.
425 ///
426 /// # Arguments
427 ///
428 /// - `I` - The serialized pseudo rules string.
429 ///
430 /// # Returns
431 ///
432 /// - `Vec<PseudoRule>` - The parsed pseudo rules.
433 pub fn parse_pseudo_rules<I>(input: I) -> Vec<PseudoRule>
434 where
435 I: AsRef<str>,
436 {
437 let mut remaining: &str = input.as_ref();
438 let mut rules: Vec<PseudoRule> = Vec::new();
439 while !remaining.is_empty() {
440 let selector_end: Option<usize> = remaining.find(CSS_RULE_OPEN);
441 let Some(selector_end_index) = selector_end else {
442 break;
443 };
444 let selector: &str = &remaining[..selector_end_index];
445 let after_selector: &str = remaining[selector_end_index..]
446 .strip_prefix(CSS_RULE_OPEN)
447 .unwrap_or_default();
448 let style_end: Option<usize> = after_selector.find(CHAR_CSS_RULE_CLOSE);
449 let Some(style_end_index) = style_end else {
450 break;
451 };
452 let style: &str = &after_selector[..style_end_index];
453 if !selector.is_empty() && !style.is_empty() {
454 rules.push(PseudoRule::new(selector.to_string(), style.to_string()));
455 }
456 remaining = after_selector[style_end_index..]
457 .strip_prefix(CHAR_CSS_RULE_CLOSE)
458 .unwrap_or_default();
459 }
460 rules
461 }
462
463 /// Parses media query rules from a compact serialization string.
464 ///
465 /// The serialization format is:
466 /// `@media query { key: value; ::selector { key: value; } }@media query2 { ... }`
467 /// This is used by the `class!` macro for fully static class definitions
468 /// where media rules can be computed at compile time.
469 /// Supports nested pseudo-element blocks inside media query blocks.
470 ///
471 /// # Arguments
472 ///
473 /// - `S` - The serialized media rules string.
474 ///
475 /// # Returns
476 ///
477 /// - `Vec<MediaRule>` - The parsed media rules.
478 pub fn parse_media_rules<S>(input: S) -> Vec<MediaRule>
479 where
480 S: AsRef<str>,
481 {
482 let input: &str = input.as_ref();
483 let mut rules: Vec<MediaRule> = Vec::new();
484 let mut remaining: &str = input;
485 while !remaining.is_empty() {
486 if !remaining.starts_with(CSS_MEDIA_PREFIX) {
487 break;
488 }
489 let after_prefix: &str = remaining.strip_prefix(CSS_MEDIA_PREFIX).unwrap_or_default();
490 let query_end: Option<usize> = after_prefix.find(CSS_RULE_OPEN);
491 let Some(query_end_index) = query_end else {
492 break;
493 };
494 let query: &str = &after_prefix[..query_end_index];
495 let after_query: &str = after_prefix[query_end_index..]
496 .strip_prefix(CSS_RULE_OPEN)
497 .unwrap_or_default();
498 let mut depth: usize = 1;
499 let mut close_pos: usize = 0;
500 for (index, char_value) in after_query.char_indices() {
501 if char_value == '{' {
502 depth += 1;
503 } else if char_value == '}' {
504 depth -= 1;
505 if depth == 0 {
506 close_pos = index;
507 break;
508 }
509 }
510 }
511 if close_pos == 0 {
512 break;
513 }
514 let body: &str = &after_query[..close_pos];
515 let (style, pseudo_rules): (String, Vec<PseudoRule>) = Self::parse_media_body(body);
516 if !query.is_empty() && (!style.is_empty() || !pseudo_rules.is_empty()) {
517 rules.push(MediaRule::new(query.to_string(), style, pseudo_rules));
518 }
519 remaining = after_query[close_pos..]
520 .strip_prefix(CHAR_CSS_RULE_CLOSE)
521 .unwrap_or_default();
522 }
523 rules
524 }
525
526 /// Parses the body of a media rule, separating top-level style declarations
527 /// from nested pseudo-element blocks.
528 ///
529 /// # Arguments
530 ///
531 /// - `&str` - The media rule body content (between the outer braces).
532 ///
533 /// # Returns
534 ///
535 /// - `(String, Vec<PseudoRule>)` - A tuple of the style string and pseudo rules.
536 fn parse_media_body(body: &str) -> (String, Vec<PseudoRule>) {
537 let mut style_parts: String = String::new();
538 let mut pseudo_rules: Vec<PseudoRule> = Vec::new();
539 let mut remaining: &str = body;
540 while !remaining.is_empty() {
541 let brace_pos: Option<usize> = remaining.find('{');
542 match brace_pos {
543 Some(pos) => {
544 let before_brace: &str = remaining[..pos].trim();
545 if before_brace.starts_with("::") || before_brace.starts_with(':') {
546 let selector: &str = before_brace;
547 let after_brace: &str = &remaining[pos + 1..];
548 let mut depth: usize = 1;
549 let mut close_pos: usize = 0;
550 for (index, char_value) in after_brace.char_indices() {
551 if char_value == '{' {
552 depth += 1;
553 } else if char_value == '}' {
554 depth -= 1;
555 if depth == 0 {
556 close_pos = index;
557 break;
558 }
559 }
560 }
561 if close_pos > 0 {
562 let inner_style: &str = after_brace[..close_pos].trim();
563 if !selector.is_empty() && !inner_style.is_empty() {
564 pseudo_rules.push(PseudoRule::new(
565 selector.to_string(),
566 inner_style.to_string(),
567 ));
568 }
569 remaining = after_brace[close_pos + 1..].trim_start();
570 continue;
571 }
572 break;
573 } else {
574 style_parts.push_str(before_brace);
575 style_parts.push(' ');
576 let after_brace: &str = &remaining[pos + 1..];
577 let mut depth: usize = 1;
578 let mut close_pos: usize = 0;
579 for (index, char_value) in after_brace.char_indices() {
580 if char_value == '{' {
581 depth += 1;
582 } else if char_value == '}' {
583 depth -= 1;
584 if depth == 0 {
585 close_pos = index;
586 break;
587 }
588 }
589 }
590 if close_pos > 0 {
591 style_parts.push_str(after_brace[..close_pos].trim());
592 style_parts.push(' ');
593 remaining = after_brace[close_pos + 1..].trim_start();
594 continue;
595 }
596 break;
597 }
598 }
599 None => {
600 style_parts.push_str(remaining.trim());
601 break;
602 }
603 }
604 }
605 (style_parts.trim().to_string(), pseudo_rules)
606 }
607
608 /// Injects this class's styles into the DOM if not already present.
609 ///
610 /// Uses a global `HashSet` to track injected class names, avoiding the
611 /// expensive `existing_css.contains(css)` full-text search on every call.
612 /// Builds the class rule, pseudo-class rules, and media rules as CSS text,
613 /// then appends them directly to the `<style>` element via
614 /// `append_child` with a new text node — no read-modify-write of the
615 /// entire stylesheet content.
616 ///
617 /// # Panics
618 ///
619 /// Panics if `window()` or `document()` is unavailable on the current platform.
620 pub fn inject_style(&self) {
621 let class_name: &String = self.get_name();
622 if InjectedClassesCell::is_injected(class_name) {
623 return;
624 }
625 InjectedClassesCell::mark_injected(class_name);
626 let raw_name: String = self.get_name().clone();
627 let mut escaped_name: String = String::with_capacity(raw_name.len() * 2);
628 for ch in raw_name.chars() {
629 if ch.is_ascii_alphanumeric() || ch == CHAR_HYPHEN || ch == CHAR_UNDERSCORE {
630 escaped_name.push(ch);
631 } else {
632 escaped_name.push(CHAR_CSS_ESCAPE);
633 escaped_name.push(ch);
634 }
635 }
636 let mut css_text: String = format!(
637 "{CHAR_CSS_CLASS_PREFIX}{escaped_name}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
638 self.get_style()
639 );
640 for pseudo_rule in self.get_pseudo_rules() {
641 if !pseudo_rule.get_style().is_empty() {
642 css_text = format!(
643 "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
644 escaped_name,
645 pseudo_rule.get_selector(),
646 pseudo_rule.get_style()
647 );
648 }
649 }
650 for media_rule in self.get_media_rules() {
651 if !media_rule.get_query().is_empty() {
652 let mut media_body: String = format!(
653 "{CHAR_CSS_CLASS_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
654 escaped_name,
655 media_rule.get_style()
656 );
657 for pseudo_rule in media_rule.get_pseudo_rules() {
658 if !pseudo_rule.get_style().is_empty() {
659 media_body = format!(
660 "{media_body} {CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
661 escaped_name,
662 pseudo_rule.get_selector(),
663 pseudo_rule.get_style()
664 );
665 }
666 }
667 css_text = format!(
668 "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CSS_MEDIA_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
669 media_rule.get_query(),
670 media_body
671 );
672 }
673 }
674 Self::append_css(&css_text);
675 }
676
677 /// Appends CSS text directly to the shared `<style>` element.
678 ///
679 /// Creates a new text node and appends it as a child of the `<style>`
680 /// element, avoiding the read-modify-write pattern of reading the entire
681 /// `innerText`, concatenating, and setting it back.
682 ///
683 /// # Arguments
684 ///
685 /// - `&str` - The CSS text to append.
686 ///
687 /// The `cfg!` guard matches the one `Scheduler::current_time` already
688 /// uses: `window()` reads a wasm-only imported static, so on a non-wasm
689 /// target it panics with "cannot access imported statics on non-wasm
690 /// targets" rather than returning `None`. That panic short-circuits the
691 /// graceful `match window() { None => return }` below, and it fires from
692 /// `OnceLock<Css>::get_or_init` — the first time any `c_*()` class
693 /// function is called. Every styled view component calls one, so
694 /// without this guard none of them can be rendered in a native
695 /// `cargo test` process. On wasm the guard is a constant `true` and
696 /// this function behaves exactly as before.
697 fn append_css(css_text: &str) {
698 if !cfg!(target_arch = "wasm32") {
699 return;
700 }
701 let style_id: &str = EUV_CSS_INJECTED_ID;
702 let window_value: Window = match window() {
703 Some(window_instance) => window_instance,
704 None => return,
705 };
706 let document: Document = match window_value.document() {
707 Some(document_instance) => document_instance,
708 None => return,
709 };
710 let style_element: HtmlStyleElement = match document.get_element_by_id(style_id) {
711 Some(existing_element) => match existing_element.dyn_into::<HtmlStyleElement>() {
712 Ok(element) => element,
713 Err(_err) => return,
714 },
715 None => {
716 let created: Element = match document.create_element(STYLE_TAG) {
717 Ok(element) => element,
718 Err(_err) => return,
719 };
720 let style_element_from_id: HtmlStyleElement =
721 match created.dyn_into::<HtmlStyleElement>() {
722 Ok(element) => element,
723 Err(_err) => return,
724 };
725 style_element_from_id.set_id(style_id);
726 if let Some(head) = document.head() {
727 let _: Result<Node, JsValue> = head.append_child(&style_element_from_id);
728 }
729 style_element_from_id
730 }
731 };
732 if !css_text.is_empty() {
733 let text_node: Text = document.create_text_node(css_text);
734 let _: Result<Node, JsValue> = style_element.append_child(&text_node);
735 }
736 }
737
738 /// Builds a CSS style string from an array of key-value pairs.
739 ///
740 /// This function is used by the `html!` macro to convert static `style:`
741 /// attributes into a CSS string without allocating intermediate objects.
742 ///
743 /// # Arguments
744 ///
745 /// - `&[(K, V)]` - An array of CSS property name-value pairs.
746 ///
747 /// # Returns
748 ///
749 /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
750 pub fn style_string<K, V>(props: &[(K, V)]) -> String
751 where
752 K: AsRef<str>,
753 V: AsRef<str>,
754 {
755 // Single pre-sized `String` instead of per-prop `format!` +
756 // intermediate `Vec<String>` + `join` (was N + 2 allocations).
757 let total_len: usize = props
758 .iter()
759 .map(|(key, value): &(K, V)| {
760 key.as_ref().len() + CSS_PROP_SEPARATOR.len() + value.as_ref().len() + 2
761 })
762 .sum();
763 let mut out: String = String::with_capacity(total_len);
764 for (index, (key, value)) in props.iter().enumerate() {
765 if index > 0 {
766 out.push_str(CHAR_SPACE);
767 }
768 out.push_str(key.as_ref());
769 out.push_str(CSS_PROP_SEPARATOR);
770 out.push_str(value.as_ref());
771 out.push(CHAR_CSS_DECL_TERMINATOR);
772 }
773 out
774 }
775
776 /// Builds a stable suffix for a class name from a dynamic parameter value.
777 ///
778 /// Used by the `class!` macro when a parameter is wrapped in `{}` in the
779 /// class body. Wrapping a parameter opts it into value-dependent class
780 /// names, so each distinct value can inject its own CSS rule.
781 ///
782 /// # Arguments
783 ///
784 /// - `&str` - The dynamic parameter value.
785 ///
786 /// # Returns
787 ///
788 /// - `String` - A stable hexadecimal suffix for the class name.
789 pub fn param_class_name(value: &str) -> String {
790 let mut hash: u64 = CLASS_PARAM_HASH_FNV_OFFSET;
791 for byte in value.as_bytes() {
792 hash ^= u64::from(*byte);
793 hash = hash.wrapping_mul(CLASS_PARAM_HASH_FNV_PRIME);
794 }
795 format!("{hash:x}")
796 }
797
798 /// Builds a CSS style string from owned key-value pairs.
799 ///
800 /// Used by the `html!` macro for reactive style attributes (with `if`
801 /// conditions) where values are computed at runtime.
802 ///
803 /// # Arguments
804 ///
805 /// - `&[(String, String)]` - An array of owned CSS property name-value pairs.
806 ///
807 /// # Returns
808 ///
809 /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
810 pub fn style_string_owned(props: &[(String, String)]) -> String {
811 // Single pre-sized `String` (same shape as `style_string`).
812 let total_len: usize = props
813 .iter()
814 .map(|(key, value): &(String, String)| {
815 key.len() + CSS_PROP_SEPARATOR.len() + value.len() + 2
816 })
817 .sum();
818 let mut out: String = String::with_capacity(total_len);
819 for (index, (key, value)) in props.iter().enumerate() {
820 if index > 0 {
821 out.push_str(CHAR_SPACE);
822 }
823 out.push_str(key);
824 out.push_str(CSS_PROP_SEPARATOR);
825 out.push_str(value);
826 out.push(CHAR_CSS_DECL_TERMINATOR);
827 }
828 out
829 }
830
831 /// Injects CSS text into the shared `<style>` element in the DOM.
832 ///
833 /// Delegates to [`Css::append_css`] for the actual DOM append.
834 /// Unlike the previous implementation, this does not read the existing
835 /// stylesheet content or perform a full-text `contains` search.
836 ///
837 /// # Arguments
838 ///
839 /// - `S` - The CSS text to inject (e.g., reset styles, keyframes, media queries).
840 ///
841 /// # Panics
842 ///
843 /// Panics if `window()` or `document()` is unavailable on the current platform.
844 pub fn inject_css<S>(css_text: S)
845 where
846 S: AsRef<str>,
847 {
848 let css_text: &str = css_text.as_ref();
849 Self::append_css(css_text);
850 }
851}
852
853/// Displays the CSS class name.
854///
855/// This enables `format!("{css}")` to produce the class name string,
856/// which is required for reactive `if` conditions in `class:` attributes.
857impl Display for Css {
858 /// Formats the CSS class as its name string.
859 ///
860 /// # Arguments
861 ///
862 /// - `&mut Formatter<'_>` - The formatter.
863 ///
864 /// # Returns
865 ///
866 /// - `fmt::Result` - The formatting result.
867 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
868 write!(formatter, "{}", self.get_name())
869 }
870}