1use azul_core::{
26 callbacks::{CoreCallback, CoreCallbackData, Update},
27 dom::{Dom, EventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec},
28 refany::{OptionRefAny, RefAny},
29};
30use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
31use azul_css::{
32 props::{
33 basic::{color::ColorU, StyleFontSize},
34 layout::{LayoutDisplay, LayoutPosition, LayoutFlexGrow, LayoutTop, LayoutLeft, LayoutPaddingLeft, LayoutPaddingRight, LayoutPaddingTop, LayoutPaddingBottom},
35 property::{CssProperty, StyleWhiteSpaceValue},
36 style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextColor, StyleWhiteSpace, StyleOpacity},
37 },
38 AzString,
39};
40
41use crate::callbacks::CallbackInfo;
42
43static TOOLTIP_WRAPPER_CLASS: &[IdOrClass] =
44 &[Class(AzString::from_const_str("__azul-native-tooltip"))];
45static TOOLTIP_TIP_CLASS: &[IdOrClass] =
46 &[Class(AzString::from_const_str("__azul-native-tooltip-tip"))];
47
48const TIP_OFFSET_Y: isize = 22;
52const TIP_RADIUS: isize = 4;
53
54const TIP_BG_COLOR: ColorU = ColorU {
57 r: 51,
58 g: 51,
59 b: 51,
60 a: 240,
61};
62const TIP_TEXT_COLOR: ColorU = ColorU {
64 r: 255,
65 g: 255,
66 b: 255,
67 a: 255,
68};
69
70const TIP_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(TIP_BG_COLOR)];
71const TIP_BG: StyleBackgroundContentVec = StyleBackgroundContentVec::from_const_slice(TIP_BG_ITEMS);
72
73static TOOLTIP_WRAPPER_STYLE: &[CssPropertyWithConditions] = &[
76 CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineBlock)),
77 CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Relative)),
78 CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
79];
80
81static TOOLTIP_TIP_STYLE: &[CssPropertyWithConditions] = &[
83 CssPropertyWithConditions::simple(CssProperty::const_position(LayoutPosition::Absolute)),
84 CssPropertyWithConditions::simple(CssProperty::const_top(LayoutTop::const_px(TIP_OFFSET_Y))),
85 CssPropertyWithConditions::simple(CssProperty::const_left(LayoutLeft::const_px(0))),
86 CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
87 8,
88 ))),
89 CssPropertyWithConditions::simple(CssProperty::const_padding_right(
90 LayoutPaddingRight::const_px(8),
91 )),
92 CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
93 CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
94 LayoutPaddingBottom::const_px(4),
95 )),
96 CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
97 StyleBorderTopLeftRadius::const_px(TIP_RADIUS),
98 )),
99 CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
100 StyleBorderTopRightRadius::const_px(TIP_RADIUS),
101 )),
102 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
103 StyleBorderBottomLeftRadius::const_px(TIP_RADIUS),
104 )),
105 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
106 StyleBorderBottomRightRadius::const_px(TIP_RADIUS),
107 )),
108 CssPropertyWithConditions::simple(CssProperty::const_background_content(TIP_BG)),
109 CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
110 inner: TIP_TEXT_COLOR,
111 })),
112 CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
113 CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
115 StyleWhiteSpace::Nowrap,
116 ))),
117 CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
119];
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123#[repr(C)]
124pub struct Tooltip {
125 pub anchor: Dom,
127 pub text: AzString,
129 pub wrapper_style: CssPropertyWithConditionsVec,
131 pub tip_style: CssPropertyWithConditionsVec,
133}
134
135impl Default for Tooltip {
136 fn default() -> Self {
137 Self::new(Dom::default(), AzString::from_const_str(""))
138 }
139}
140
141impl Tooltip {
142 #[must_use] pub fn new(anchor: Dom, text: AzString) -> Self {
144 Self {
145 anchor,
146 text,
147 wrapper_style: CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_WRAPPER_STYLE),
148 tip_style: CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_TIP_STYLE),
149 }
150 }
151
152 #[inline]
154 pub fn set_text(&mut self, text: AzString) {
155 self.text = text;
156 }
157
158 #[inline]
160 #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
161 self.set_text(text);
162 self
163 }
164
165 #[inline]
167 pub fn set_tip_style(&mut self, style: CssPropertyWithConditionsVec) {
168 self.tip_style = style;
169 }
170
171 #[inline]
173 #[must_use] pub fn with_tip_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
174 self.set_tip_style(style);
175 self
176 }
177
178 #[inline]
179 #[must_use] pub fn swap_with_default(&mut self) -> Self {
180 let mut s = Self::default();
181 core::mem::swap(&mut s, self);
182 s
183 }
184
185 #[must_use] pub fn dom(self) -> Dom {
186 let marker = RefAny::new(());
189
190 let tip = Dom::create_text(self.text)
191 .with_ids_and_classes(IdOrClassVec::from_const_slice(TOOLTIP_TIP_CLASS))
192 .with_css_props(self.tip_style);
193
194 Dom::create_div()
195 .with_ids_and_classes(IdOrClassVec::from_const_slice(TOOLTIP_WRAPPER_CLASS))
196 .with_css_props(self.wrapper_style)
197 .with_callbacks(
198 vec![
199 CoreCallbackData {
200 event: EventFilter::Hover(HoverEventFilter::MouseEnter),
201 callback: CoreCallback {
202 cb: on_tooltip_enter as usize,
203 ctx: OptionRefAny::None,
204 },
205 refany: marker.clone(),
206 },
207 CoreCallbackData {
208 event: EventFilter::Hover(HoverEventFilter::MouseLeave),
209 callback: CoreCallback {
210 cb: on_tooltip_leave as usize,
211 ctx: OptionRefAny::None,
212 },
213 refany: marker,
214 },
215 ]
216 .into(),
217 )
218 .with_children(vec![self.anchor, tip].into())
220 }
221}
222
223fn tip_of_wrapper(info: &CallbackInfo) -> Option<azul_core::dom::DomNodeId> {
225 let wrapper = info.get_hit_node();
226 let anchor = info.get_first_child(wrapper)?;
227 info.get_next_sibling(anchor)
228}
229
230extern "C" fn on_tooltip_enter(_data: RefAny, mut info: CallbackInfo) -> Update {
232 if let Some(tip) = tip_of_wrapper(&info) {
233 info.set_css_property(tip, CssProperty::const_opacity(StyleOpacity::const_new(100)));
234 }
235 Update::DoNothing
236}
237
238extern "C" fn on_tooltip_leave(_data: RefAny, mut info: CallbackInfo) -> Update {
240 if let Some(tip) = tip_of_wrapper(&info) {
241 info.set_css_property(tip, CssProperty::const_opacity(StyleOpacity::const_new(0)));
242 }
243 Update::DoNothing
244}
245
246impl From<Tooltip> for Dom {
247 fn from(t: Tooltip) -> Self {
248 t.dom()
249 }
250}
251
252#[cfg(test)]
253#[allow(clippy::assertions_on_constants)]
258mod autotest_generated {
259 use std::{
260 collections::{BTreeMap, HashMap},
261 sync::{Arc, Mutex},
262 };
263
264 use azul_core::{
265 dom::{DomId, DomNodeId, NodeId, NodeType},
266 geom::{LogicalRect, OptionLogicalPosition},
267 gl::OptionGlContextPtr,
268 hit_test::ScrollPosition,
269 resources::RendererResources,
270 styled_dom::{NodeHierarchyItemId, StyledDom},
271 window::{MonitorVec, RawWindowHandle},
272 };
273 use azul_css::{props::property::CssPropertyType, system::SystemStyle};
274 use rust_fontconfig::FcFontCache;
275
276 use super::*;
277 #[cfg(feature = "icu")]
278 use crate::icu::IcuLocalizerHandle;
279 use crate::{
280 callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
281 solver3::{display_list::DisplayList, layout_tree::LayoutTree},
282 window::{DomLayoutResult, LayoutWindow},
283 window_state::FullWindowState,
284 };
285
286 const WRAPPER_CLASS_NAME: &str = "__azul-native-tooltip";
291 const TIP_CLASS_NAME: &str = "__azul-native-tooltip-tip";
292
293 fn has_class(node: &Dom, name: &str) -> bool {
295 node.root
296 .get_ids_and_classes()
297 .as_ref()
298 .iter()
299 .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
300 }
301
302 fn text_of(node: &Dom) -> Option<&str> {
304 match node.root.get_node_type() {
305 NodeType::Text(s) => Some(s.as_ref().as_str()),
306 _ => None,
307 }
308 }
309
310 fn inline_properties(node: &Dom) -> Vec<CssProperty> {
312 node.root
313 .style
314 .iter_inline_properties()
315 .map(|(p, _)| p.clone())
316 .collect()
317 }
318
319 fn prop_types(style: &CssPropertyWithConditionsVec) -> Vec<CssPropertyType> {
321 style
322 .as_ref()
323 .iter()
324 .map(|p| p.property.get_type())
325 .collect()
326 }
327
328 fn declared_opacities(style: &CssPropertyWithConditionsVec) -> Vec<f32> {
332 style
333 .as_ref()
334 .iter()
335 .filter_map(|p| match &p.property {
336 CssProperty::Opacity(v) => v.get_property().map(|o| o.inner.normalized()),
337 _ => None,
338 })
339 .collect()
340 }
341
342 fn declared_positions(style: &CssPropertyWithConditionsVec) -> Vec<LayoutPosition> {
343 style
344 .as_ref()
345 .iter()
346 .filter_map(|p| match &p.property {
347 CssProperty::Position(v) => v.get_property().copied(),
348 _ => None,
349 })
350 .collect()
351 }
352
353 fn declared_displays(style: &CssPropertyWithConditionsVec) -> Vec<LayoutDisplay> {
354 style
355 .as_ref()
356 .iter()
357 .filter_map(|p| match &p.property {
358 CssProperty::Display(v) => v.get_property().copied(),
359 _ => None,
360 })
361 .collect()
362 }
363
364 fn opacity_ty() -> CssPropertyType {
366 CssProperty::const_opacity(StyleOpacity::const_new(0)).get_type()
367 }
368
369 fn style_of(props: Vec<CssProperty>) -> CssPropertyWithConditionsVec {
371 props
372 .into_iter()
373 .map(CssPropertyWithConditions::simple)
374 .collect::<Vec<_>>()
375 .into()
376 }
377
378 fn nested_anchor(depth: usize) -> Dom {
381 let mut d = Dom::create_div().with_child(Dom::create_text("leaf"));
382 for _ in 0..depth {
383 d = Dom::create_div().with_child(d);
384 }
385 d
386 }
387
388 fn adversarial_texts() -> Vec<String> {
392 vec![
393 String::new(),
394 " ".to_string(),
395 "\0".to_string(),
396 "a\0b\0".to_string(),
397 "\n\r\t\u{0b}\u{0c}".to_string(),
398 "🦀".to_string(),
399 "👨👩👧👦".to_string(),
400 "e\u{0301}\u{0301}\u{0301}\u{0301}".to_string(),
401 "\u{202e}gnirts detrevni".to_string(),
402 "\u{feff}bom-prefixed".to_string(),
403 "fullwidth".to_string(),
404 "\u{fdfa}".to_string(),
405 "line\nbreak".to_string(),
406 "a".repeat(100_000),
407 "🦀".repeat(50_000),
408 ]
409 }
410
411 fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
419 DomLayoutResult {
420 styled_dom,
421 layout_tree: LayoutTree {
422 nodes: Vec::new(),
423 warm: Vec::new(),
424 cold: Vec::new(),
425 root: 0,
426 dom_to_layout: BTreeMap::new(),
427 children_arena: Vec::new(),
428 children_offsets: Vec::new(),
429 subtree_needs_intrinsic: Vec::new(),
430 },
431 calculated_positions: Vec::new(),
432 viewport: LogicalRect::zero(),
433 display_list: DisplayList::default(),
434 scroll_ids: HashMap::new(),
435 scroll_id_to_node_id: HashMap::new(),
436 }
437 }
438
439 fn node(index: usize) -> NodeHierarchyItemId {
440 NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index)))
441 }
442
443 fn with_info<R>(
447 styled: Option<StyledDom>,
448 hit: NodeHierarchyItemId,
449 f: impl FnOnce(CallbackInfo) -> R,
450 ) -> (R, Vec<CallbackChange>) {
451 let mut layout_window =
452 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
453 if let Some(sd) = styled {
454 layout_window
455 .layout_results
456 .insert(DomId::ROOT_ID, layout_result(sd));
457 }
458
459 let renderer_resources = RendererResources::default();
460 let previous_window_state: Option<FullWindowState> = None;
461 let current_window_state = FullWindowState::default();
462 let gl_context = OptionGlContextPtr::None;
463 let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
464 BTreeMap::new();
465 let window_handle = RawWindowHandle::Unsupported;
466 let system_callbacks = ExternalSystemCallbacks::rust_internal();
467
468 let ref_data = CallbackInfoRefData {
469 layout_window: &layout_window,
470 renderer_resources: &renderer_resources,
471 previous_window_state: &previous_window_state,
472 current_window_state: ¤t_window_state,
473 gl_context: &gl_context,
474 current_scroll_manager: &scroll_states,
475 current_window_handle: &window_handle,
476 system_callbacks: &system_callbacks,
477 system_style: Arc::new(SystemStyle::default()),
478 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
479 #[cfg(feature = "icu")]
480 icu_localizer: IcuLocalizerHandle::default(),
481 ctx: OptionRefAny::None,
482 };
483
484 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
485
486 let info = CallbackInfo::new(
487 &ref_data,
488 &changes,
489 DomNodeId {
490 dom: DomId::ROOT_ID,
491 node: hit,
492 },
493 OptionLogicalPosition::None,
494 OptionLogicalPosition::None,
495 );
496
497 let out = f(info);
498 let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
499 (out, recorded)
500 }
501
502 fn css_writes(changes: &[CallbackChange]) -> Vec<(usize, Vec<CssProperty>)> {
504 changes
505 .iter()
506 .filter_map(|c| match c {
507 CallbackChange::ChangeNodeCssProperties {
508 node_id, properties, ..
509 } => Some((node_id.index(), properties.as_ref().to_vec())),
510 _ => None,
511 })
512 .collect()
513 }
514
515 fn opacity_writes(changes: &[CallbackChange]) -> Vec<(usize, f32)> {
517 let mut out = Vec::new();
518 for (idx, props) in css_writes(changes) {
519 for p in &props {
520 if let CssProperty::Opacity(v) = p {
521 if let Some(o) = v.get_property() {
522 out.push((idx, o.inner.normalized()));
523 }
524 }
525 }
526 }
527 out
528 }
529
530 fn index_of_class(styled: &StyledDom, class: &str) -> Option<usize> {
532 styled.node_data.as_ref().iter().position(|nd| {
533 nd.get_ids_and_classes()
534 .as_ref()
535 .iter()
536 .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == class))
537 })
538 }
539
540 fn anchor_tip_dom() -> StyledDom {
543 let styled = StyledDom::create_from_dom(
544 Dom::create_div()
545 .with_child(Dom::create_div())
546 .with_child(Dom::create_div()),
547 );
548 assert_eq!(
549 styled.node_hierarchy.as_ref().len(),
550 3,
551 "fixture must flatten to exactly wrapper/anchor/tip"
552 );
553 styled
554 }
555
556 #[test]
561 fn new_stores_anchor_and_text_verbatim() {
562 let anchor = Dom::create_div().with_child(Dom::create_text("anchor"));
563 let text = AzString::from("tip".to_string());
564 let t = Tooltip::new(anchor.clone(), text.clone());
565
566 assert_eq!(t.anchor, anchor, "the anchor must be stored unmodified");
567 assert_eq!(t.text, text, "the text must be stored unmodified");
568 }
569
570 #[test]
571 fn new_uses_the_static_style_tables() {
572 let t = Tooltip::new(Dom::create_div(), AzString::from_const_str("x"));
573
574 assert_eq!(
575 t.wrapper_style,
576 CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_WRAPPER_STYLE)
577 );
578 assert_eq!(
579 t.tip_style,
580 CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_TIP_STYLE)
581 );
582 assert_eq!(t.wrapper_style.len(), TOOLTIP_WRAPPER_STYLE.len());
583 assert_eq!(t.tip_style.len(), TOOLTIP_TIP_STYLE.len());
584 }
585
586 #[test]
587 fn new_is_pure_and_independent_of_the_arguments() {
588 let a = Tooltip::new(Dom::create_div(), AzString::from_const_str(""));
591 let b = Tooltip::new(
592 nested_anchor(8),
593 AzString::from("🦀".repeat(1000)),
594 );
595
596 assert_eq!(a.wrapper_style, b.wrapper_style);
597 assert_eq!(a.tip_style, b.tip_style);
598 }
599
600 #[test]
601 fn new_survives_adversarial_text() {
602 for s in adversarial_texts() {
603 let t = Tooltip::new(Dom::create_div(), AzString::from(s.clone()));
604 assert_eq!(
605 t.text.as_str(),
606 s.as_str(),
607 "text must round-trip byte-for-byte through AzString"
608 );
609 assert_eq!(
610 t.text.as_str().len(),
611 s.len(),
612 "byte length must be preserved (no re-encoding / truncation at NUL)"
613 );
614 }
615 }
616
617 #[test]
618 fn new_with_a_deeply_nested_anchor_keeps_the_child_count_consistent() {
619 let anchor = nested_anchor(64);
620 let expected = anchor.estimated_total_children;
621 let t = Tooltip::new(anchor.clone(), AzString::from_const_str("deep"));
622
623 assert_eq!(t.anchor, anchor);
624 assert_eq!(
625 t.anchor.estimated_total_children, expected,
626 "the constructor must not disturb the anchor's cached descendant count"
627 );
628 }
629
630 #[test]
631 fn new_with_a_very_wide_anchor_does_not_panic() {
632 let anchor = Dom::create_div()
633 .with_children((0..2000).map(|_| Dom::create_div()).collect::<Vec<_>>().into());
634 let t = Tooltip::new(anchor, AzString::from_const_str("wide"));
635
636 assert_eq!(t.anchor.children.as_ref().len(), 2000);
637 }
638
639 #[test]
640 fn default_is_an_empty_body_anchor_with_empty_text() {
641 let d = Tooltip::default();
642
643 assert_eq!(d.text.as_str(), "");
644 assert_eq!(d.anchor, Dom::default());
645 assert_eq!(
646 d,
647 Tooltip::new(Dom::default(), AzString::from_const_str("")),
648 "Default must agree with the documented constructor call"
649 );
650 }
651
652 #[test]
657 fn set_text_and_with_text_agree_and_touch_nothing_else() {
658 for s in adversarial_texts() {
659 let base = Tooltip::new(
660 Dom::create_div().with_child(Dom::create_text("a")),
661 AzString::from_const_str("initial"),
662 );
663
664 let mut mutated = base.clone();
665 mutated.set_text(AzString::from(s.clone()));
666 let built = base.clone().with_text(AzString::from(s.clone()));
667
668 assert_eq!(mutated, built, "with_text must be set_text + self");
669 assert_eq!(mutated.text.as_str(), s.as_str());
670 assert_eq!(mutated.anchor, base.anchor, "the anchor must be untouched");
671 assert_eq!(mutated.wrapper_style, base.wrapper_style);
672 assert_eq!(mutated.tip_style, base.tip_style);
673 }
674 }
675
676 #[test]
677 fn set_text_is_last_write_wins() {
678 let mut t = Tooltip::default();
679 let huge = "x".repeat(200_000);
680
681 t.set_text(AzString::from(huge.clone()));
682 assert_eq!(t.text.as_str().len(), huge.len());
683
684 t.set_text(AzString::from_const_str(""));
685 assert_eq!(t.text.as_str(), "", "a later empty write must win");
686
687 t.set_text(AzString::from("🦀".to_string()));
688 assert_eq!(t.text.as_str(), "🦀");
689 }
690
691 #[test]
692 fn text_and_tip_style_setters_commute() {
693 let style = style_of(vec![CssProperty::const_opacity(StyleOpacity::const_new(42))]);
694 let text = AzString::from("both".to_string());
695
696 let a = Tooltip::default()
697 .with_text(text.clone())
698 .with_tip_style(style.clone());
699 let b = Tooltip::default()
700 .with_tip_style(style)
701 .with_text(text);
702
703 assert_eq!(a, b, "the two builder setters must be independent");
704 }
705
706 #[test]
711 fn set_tip_style_and_with_tip_style_agree() {
712 let style = style_of(vec![
713 CssProperty::const_position(LayoutPosition::Fixed),
714 CssProperty::const_opacity(StyleOpacity::const_new(100)),
715 ]);
716
717 let mut mutated = Tooltip::default();
718 mutated.set_tip_style(style.clone());
719 let built = Tooltip::default().with_tip_style(style.clone());
720
721 assert_eq!(mutated, built);
722 assert_eq!(mutated.tip_style, style, "the style must be stored verbatim");
723 }
724
725 #[test]
726 fn set_tip_style_does_not_touch_the_wrapper_style() {
727 let base = Tooltip::default();
730 let mut t = base.clone();
731 t.set_tip_style(CssPropertyWithConditionsVec::from_const_slice(&[]));
732
733 assert_eq!(t.wrapper_style, base.wrapper_style);
734 assert_eq!(t.text, base.text);
735 assert_eq!(t.anchor, base.anchor);
736 }
737
738 #[test]
739 fn tip_style_can_be_emptied_and_the_widget_still_builds() {
740 let t = Tooltip::new(Dom::create_div(), AzString::from_const_str("naked"))
741 .with_tip_style(CssPropertyWithConditionsVec::from_const_slice(&[]));
742 assert_eq!(t.tip_style.len(), 0);
743
744 let dom = t.dom();
745 let tip = &dom.children.as_ref()[1];
746 assert!(
747 inline_properties(tip).is_empty(),
748 "an empty override must produce an unstyled tip, not the default table"
749 );
750 assert_eq!(text_of(tip), Some("naked"));
751 }
752
753 #[test]
754 fn a_huge_tip_style_is_stored_verbatim() {
755 let props: Vec<CssProperty> = (0..10_000)
756 .map(|i| CssProperty::const_opacity(StyleOpacity::const_new(i % 101)))
757 .collect();
758 let style = style_of(props);
759
760 let t = Tooltip::default().with_tip_style(style.clone());
761 assert_eq!(t.tip_style.len(), 10_000);
762 assert_eq!(t.tip_style, style);
763
764 let dom = t.dom();
765 assert_eq!(
766 inline_properties(&dom.children.as_ref()[1]).len(),
767 10_000,
768 "every declaration must survive the DOM build"
769 );
770 }
771
772 #[test]
777 fn swap_with_default_returns_the_old_value_and_leaves_a_default() {
778 let original = Tooltip::new(
779 Dom::create_div().with_child(Dom::create_text("anchor")),
780 AzString::from("tip".to_string()),
781 )
782 .with_tip_style(style_of(vec![CssProperty::const_opacity(
783 StyleOpacity::const_new(7),
784 )]));
785
786 let mut t = original.clone();
787 let taken = t.swap_with_default();
788
789 assert_eq!(taken, original, "the previous value must be handed back");
790 assert_eq!(t, Tooltip::default(), "self must be left as a default");
791 }
792
793 #[test]
794 fn swap_with_default_is_stable_under_repetition() {
795 let mut t = Tooltip::default().with_text(AzString::from("a".repeat(50_000)));
796
797 let first = t.swap_with_default();
798 assert_eq!(first.text.as_str().len(), 50_000);
799
800 for _ in 0..10 {
801 let again = t.swap_with_default();
802 assert_eq!(again, Tooltip::default());
803 assert_eq!(t, Tooltip::default());
804 }
805 }
806
807 #[test]
808 fn swap_with_default_on_a_default_is_an_identity() {
809 let mut t = Tooltip::default();
810 let taken = t.swap_with_default();
811
812 assert_eq!(taken, Tooltip::default());
813 assert_eq!(t, Tooltip::default());
814 }
815
816 #[test]
821 fn tip_style_starts_hidden_with_exactly_one_opacity_declaration() {
822 assert_eq!(
825 declared_opacities(&CssPropertyWithConditionsVec::from_const_slice(
826 TOOLTIP_TIP_STYLE
827 )),
828 vec![0.0],
829 "the tip must be hidden by default via a single opacity declaration"
830 );
831 }
832
833 #[test]
834 fn tip_style_is_absolutely_positioned_and_does_not_wrap() {
835 let style = CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_TIP_STYLE);
836
837 assert_eq!(declared_positions(&style), vec![LayoutPosition::Absolute]);
838 assert!(
839 style.as_ref().contains(&CssPropertyWithConditions::simple(
840 CssProperty::const_top(LayoutTop::const_px(TIP_OFFSET_Y))
841 )),
842 "the documented vertical offset must be declared"
843 );
844 assert!(
845 style.as_ref().contains(&CssPropertyWithConditions::simple(
846 CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(StyleWhiteSpace::Nowrap))
847 )),
848 "the tip must stay on one line"
849 );
850 }
851
852 #[test]
853 fn wrapper_style_is_an_inline_block_positioning_context() {
854 let style = CssPropertyWithConditionsVec::from_const_slice(TOOLTIP_WRAPPER_STYLE);
855
856 assert_eq!(declared_displays(&style), vec![LayoutDisplay::InlineBlock]);
857 assert_eq!(
858 declared_positions(&style),
859 vec![LayoutPosition::Relative],
860 "without `position: relative` the tip would anchor to some ancestor"
861 );
862 assert!(
863 style.as_ref().contains(&CssPropertyWithConditions::simple(
864 CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))
865 )),
866 "the wrapper must not grow past the anchor"
867 );
868 }
869
870 #[test]
871 fn neither_style_table_declares_a_property_type_twice() {
872 for (name, table) in [
873 ("wrapper", TOOLTIP_WRAPPER_STYLE),
874 ("tip", TOOLTIP_TIP_STYLE),
875 ] {
876 let style = CssPropertyWithConditionsVec::from_const_slice(table);
877 let mut types = prop_types(&style);
878 let declared = types.len();
879 assert!(declared > 0, "{name} style must not be empty");
880 types.sort_unstable();
881 types.dedup();
882 assert_eq!(
883 types.len(),
884 declared,
885 "{name}: a duplicated property type would make the later declaration \
886 silently win"
887 );
888 }
889 }
890
891 #[test]
892 fn both_style_tables_apply_unconditionally() {
893 for table in [TOOLTIP_WRAPPER_STYLE, TOOLTIP_TIP_STYLE] {
894 assert!(
895 table.iter().all(|p| p.apply_if.as_ref().is_empty()),
896 "a stray condition would leave the tooltip unstyled"
897 );
898 }
899 }
900
901 #[test]
902 fn tip_colours_are_opaque_enough_to_read() {
903 assert!(
904 TIP_BG_COLOR.a > 200,
905 "a near-transparent tip background would be unreadable"
906 );
907 assert_eq!(TIP_TEXT_COLOR.a, 255);
908 assert!(TIP_RADIUS >= 0 && TIP_OFFSET_Y > 0);
909 }
910
911 #[test]
916 fn dom_builds_a_wrapper_with_the_anchor_then_the_tip() {
917 let anchor = Dom::create_div().with_child(Dom::create_text("anchor"));
918 let dom = Tooltip::new(anchor.clone(), AzString::from_const_str("tip")).dom();
919
920 assert!(has_class(&dom, WRAPPER_CLASS_NAME));
921 assert_eq!(dom.root.get_node_type(), &NodeType::Div);
922
923 let children = dom.children.as_ref();
924 assert_eq!(children.len(), 2, "children must be exactly [anchor, tip]");
925 assert_eq!(children[0], anchor, "child 0 must be the anchor, verbatim");
926 assert!(
927 has_class(&children[1], TIP_CLASS_NAME),
928 "child 1 must be the tip"
929 );
930 assert_eq!(text_of(&children[1]), Some("tip"));
931 assert_eq!(
932 inline_properties(&children[1]).len(),
933 TOOLTIP_TIP_STYLE.len(),
934 "the tip must carry the full tip style"
935 );
936 assert_eq!(
937 inline_properties(&dom).len(),
938 TOOLTIP_WRAPPER_STYLE.len(),
939 "the wrapper must carry the full wrapper style"
940 );
941 }
942
943 #[test]
944 fn dom_preserves_adversarial_text_byte_for_byte() {
945 for s in adversarial_texts() {
946 let dom = Tooltip::new(Dom::create_div(), AzString::from(s.clone())).dom();
947 let tip = &dom.children.as_ref()[1];
948 assert_eq!(
949 text_of(tip),
950 Some(s.as_str()),
951 "the tip text must survive the DOM build unchanged"
952 );
953 }
954 }
955
956 #[test]
957 fn dom_applies_a_custom_tip_style_to_the_tip_only() {
958 let custom = style_of(vec![CssProperty::const_opacity(StyleOpacity::const_new(
959 100,
960 ))]);
961 let dom = Tooltip::new(Dom::create_div(), AzString::from_const_str("t"))
962 .with_tip_style(custom.clone())
963 .dom();
964
965 assert_eq!(
966 inline_properties(&dom.children.as_ref()[1]),
967 vec![CssProperty::const_opacity(StyleOpacity::const_new(100))],
968 "the override must replace the default tip table"
969 );
970 assert_eq!(
971 inline_properties(&dom).len(),
972 TOOLTIP_WRAPPER_STYLE.len(),
973 "the wrapper must keep its own style"
974 );
975 }
976
977 #[test]
978 fn dom_binds_exactly_mouse_enter_and_mouse_leave_on_the_wrapper() {
979 let dom = Tooltip::new(Dom::create_div(), AzString::from_const_str("t")).dom();
980 let callbacks = dom.root.callbacks.as_ref();
981
982 assert_eq!(callbacks.len(), 2, "exactly two hover handlers are expected");
983 assert_eq!(
984 callbacks[0].event,
985 EventFilter::Hover(HoverEventFilter::MouseEnter)
986 );
987 assert_eq!(
988 callbacks[1].event,
989 EventFilter::Hover(HoverEventFilter::MouseLeave)
990 );
991 assert_eq!(callbacks[0].callback.cb, on_tooltip_enter as usize);
992 assert_eq!(callbacks[1].callback.cb, on_tooltip_leave as usize);
993 assert!(matches!(callbacks[0].callback.ctx, OptionRefAny::None));
994 assert!(matches!(callbacks[1].callback.ctx, OptionRefAny::None));
995 assert_eq!(
996 callbacks[0].refany, callbacks[1].refany,
997 "both handlers must share one marker RefAny (they are stateless)"
998 );
999 }
1000
1001 #[test]
1002 fn dom_binds_no_callbacks_on_the_anchor_or_the_tip() {
1003 let dom = Tooltip::new(
1004 Dom::create_div().with_child(Dom::create_text("a")),
1005 AzString::from_const_str("t"),
1006 )
1007 .dom();
1008
1009 for (i, child) in dom.children.as_ref().iter().enumerate() {
1010 assert!(
1011 child.root.callbacks.as_ref().is_empty(),
1012 "child {i} must not carry hover handlers of its own"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn from_impl_matches_dom_structurally() {
1019 let make = || {
1022 Tooltip::new(
1023 Dom::create_div().with_child(Dom::create_text("a")),
1024 AzString::from_const_str("tip"),
1025 )
1026 };
1027 let via_from = Dom::from(make());
1028 let via_dom = make().dom();
1029
1030 assert_eq!(via_from.root.get_node_type(), via_dom.root.get_node_type());
1031 assert_eq!(
1032 via_from.root.get_ids_and_classes().as_ref(),
1033 via_dom.root.get_ids_and_classes().as_ref()
1034 );
1035 assert_eq!(via_from.root.style, via_dom.root.style);
1036 assert_eq!(via_from.children.as_ref(), via_dom.children.as_ref());
1037 assert_eq!(
1038 via_from.estimated_total_children,
1039 via_dom.estimated_total_children
1040 );
1041
1042 let (a, b) = (
1043 via_from.root.callbacks.as_ref(),
1044 via_dom.root.callbacks.as_ref(),
1045 );
1046 assert_eq!(a.len(), b.len());
1047 for (x, y) in a.iter().zip(b.iter()) {
1048 assert_eq!(x.event, y.event);
1049 assert_eq!(x.callback.cb, y.callback.cb);
1050 }
1051 }
1052
1053 #[test]
1054 fn dom_keeps_the_estimated_child_count_consistent_with_the_flattened_tree() {
1055 for depth in [0, 1, 8, 64] {
1058 let dom = Tooltip::new(nested_anchor(depth), AzString::from_const_str("t")).dom();
1059 let estimated = dom.estimated_total_children;
1060 let flattened = StyledDom::create_from_dom(dom).node_hierarchy.as_ref().len();
1061 assert_eq!(
1062 flattened,
1063 estimated + 1,
1064 "depth {depth}: the cached descendant count disagrees with the flattened tree"
1065 );
1066 }
1067 }
1068
1069 #[test]
1070 fn dom_of_a_very_wide_anchor_flattens_without_panicking() {
1071 let anchor = Dom::create_div()
1072 .with_children((0..2000).map(|_| Dom::create_div()).collect::<Vec<_>>().into());
1073 let dom = Tooltip::new(anchor, AzString::from_const_str("wide")).dom();
1074
1075 let styled = StyledDom::create_from_dom(dom);
1076 assert_eq!(
1077 styled.node_hierarchy.as_ref().len(),
1078 1 + 1 + 2000 + 1,
1079 "wrapper + anchor + 2000 grandchildren + tip"
1080 );
1081 }
1082
1083 #[test]
1084 fn dom_of_nested_tooltips_keeps_each_tip_as_the_second_child() {
1085 let inner = Tooltip::new(Dom::create_div(), AzString::from_const_str("inner")).dom();
1086 let outer = Tooltip::new(inner, AzString::from_const_str("outer")).dom();
1087
1088 let outer_children = outer.children.as_ref();
1089 assert_eq!(outer_children.len(), 2);
1090 assert_eq!(text_of(&outer_children[1]), Some("outer"));
1091
1092 let inner_children = outer_children[0].children.as_ref();
1093 assert_eq!(inner_children.len(), 2);
1094 assert_eq!(text_of(&inner_children[1]), Some("inner"));
1095 }
1096
1097 #[test]
1102 fn tip_of_wrapper_without_a_layout_result_is_none() {
1103 let (tip, changes) = with_info(None, node(0), |info| tip_of_wrapper(&info));
1104 assert_eq!(tip, None);
1105 assert!(changes.is_empty());
1106 }
1107
1108 #[test]
1109 fn tip_of_wrapper_with_a_stale_hit_node_is_none() {
1110 for stale in [3usize, 999, usize::MAX / 2] {
1111 let (tip, _) = with_info(Some(anchor_tip_dom()), node(stale), |info| {
1112 tip_of_wrapper(&info)
1113 });
1114 assert_eq!(tip, None, "node {stale} does not exist in the 3-node fixture");
1115 }
1116 }
1117
1118 #[test]
1119 fn tip_of_wrapper_with_a_none_hit_node_is_none() {
1120 let (tip, _) = with_info(
1121 Some(anchor_tip_dom()),
1122 NodeHierarchyItemId::NONE,
1123 |info| tip_of_wrapper(&info),
1124 );
1125 assert_eq!(tip, None, "an unset hit node must not resolve to a tip");
1126 }
1127
1128 #[test]
1129 fn tip_of_wrapper_on_a_childless_node_is_none() {
1130 let (tip, _) = with_info(Some(anchor_tip_dom()), node(1), |info| tip_of_wrapper(&info));
1132 assert_eq!(tip, None);
1133 }
1134
1135 #[test]
1136 fn tip_of_wrapper_without_a_second_child_is_none() {
1137 let styled = StyledDom::create_from_dom(Dom::create_div().with_child(Dom::create_div()));
1138 let (tip, _) = with_info(Some(styled), node(0), |info| tip_of_wrapper(&info));
1139 assert_eq!(
1140 tip, None,
1141 "a wrapper with a single child has no tip to reveal"
1142 );
1143 }
1144
1145 #[test]
1146 fn tip_of_wrapper_returns_the_second_child() {
1147 let (tip, _) = with_info(Some(anchor_tip_dom()), node(0), |info| tip_of_wrapper(&info));
1148 assert_eq!(
1149 tip.and_then(|t| t.node.into_crate_internal()).map(|n| n.index()),
1150 Some(2)
1151 );
1152 }
1153
1154 #[test]
1155 fn tip_of_wrapper_finds_the_tip_of_a_real_tooltip_dom() {
1156 let dom = Tooltip::new(nested_anchor(3), AzString::from_const_str("tip")).dom();
1159 let styled = StyledDom::create_from_dom(dom);
1160 let wrapper = index_of_class(&styled, WRAPPER_CLASS_NAME).expect("wrapper class missing");
1161 let expected = index_of_class(&styled, TIP_CLASS_NAME).expect("tip class missing");
1162 assert!(
1163 expected > wrapper + 1,
1164 "fixture must have a non-trivial anchor subtree between wrapper and tip"
1165 );
1166
1167 let (tip, _) = with_info(Some(styled), node(wrapper), |info| tip_of_wrapper(&info));
1168 assert_eq!(
1169 tip.and_then(|t| t.node.into_crate_internal()).map(|n| n.index()),
1170 Some(expected)
1171 );
1172 }
1173
1174 #[test]
1179 fn enter_reveals_and_leave_hides_exactly_the_tip() {
1180 for (name, handler, expected) in [
1181 (
1182 "enter",
1183 on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
1184 1.0_f32,
1185 ),
1186 (
1187 "leave",
1188 on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
1189 0.0_f32,
1190 ),
1191 ] {
1192 let (update, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
1193 handler(RefAny::new(()), info)
1194 });
1195
1196 assert_eq!(update, Update::DoNothing, "{name} must not relayout");
1197 assert_eq!(
1198 opacity_writes(&changes),
1199 vec![(2, expected)],
1200 "{name} must write exactly one opacity, on the tip node"
1201 );
1202 let writes = css_writes(&changes);
1203 assert_eq!(
1204 writes.len(),
1205 changes.len(),
1206 "{name} must only record CSS writes"
1207 );
1208 assert_eq!(writes[0].1.len(), 1, "{name} must write a single property");
1209 }
1210 }
1211
1212 #[test]
1213 fn leave_restores_the_opacity_declared_in_the_static_tip_style() {
1214 let declared = declared_opacities(&CssPropertyWithConditionsVec::from_const_slice(
1218 TOOLTIP_TIP_STYLE,
1219 ));
1220 let (_, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
1221 on_tooltip_leave(RefAny::new(()), info)
1222 });
1223
1224 assert_eq!(
1225 opacity_writes(&changes).iter().map(|(_, o)| *o).collect::<Vec<_>>(),
1226 declared
1227 );
1228 }
1229
1230 #[test]
1231 fn enter_then_leave_is_a_round_trip() {
1232 let enter = with_info(Some(anchor_tip_dom()), node(0), |info| {
1233 on_tooltip_enter(RefAny::new(()), info)
1234 })
1235 .1;
1236 let leave = with_info(Some(anchor_tip_dom()), node(0), |info| {
1237 on_tooltip_leave(RefAny::new(()), info)
1238 })
1239 .1;
1240
1241 let (e, l) = (opacity_writes(&enter), opacity_writes(&leave));
1242 assert_eq!(e.len(), 1);
1243 assert_eq!(l.len(), 1);
1244 assert_eq!(e[0].0, l[0].0, "both must target the same node");
1245 assert!(
1246 e[0].1 > l[0].1,
1247 "enter must make the tip more visible than leave ({} vs {})",
1248 e[0].1,
1249 l[0].1
1250 );
1251 assert_eq!((e[0].1, l[0].1), (1.0, 0.0));
1252 }
1253
1254 #[test]
1255 fn handlers_are_noops_when_there_is_no_tip() {
1256 let fixtures: Vec<(&str, Option<StyledDom>, NodeHierarchyItemId)> = vec![
1257 ("no layout result", None, node(0)),
1258 ("stale hit node", Some(anchor_tip_dom()), node(999)),
1259 ("none hit node", Some(anchor_tip_dom()), NodeHierarchyItemId::NONE),
1260 ("leaf hit node", Some(anchor_tip_dom()), node(1)),
1261 (
1262 "single child",
1263 Some(StyledDom::create_from_dom(
1264 Dom::create_div().with_child(Dom::create_div()),
1265 )),
1266 node(0),
1267 ),
1268 ];
1269
1270 for (name, styled, hit) in fixtures {
1271 for handler in [
1272 on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
1273 on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
1274 ] {
1275 let (update, changes) =
1276 with_info(styled.clone(), hit, |info| handler(RefAny::new(()), info));
1277 assert_eq!(update, Update::DoNothing, "{name}");
1278 assert!(
1279 changes.is_empty(),
1280 "{name}: nothing may be restyled without a tip"
1281 );
1282 }
1283 }
1284 }
1285
1286 #[test]
1287 fn handlers_ignore_their_payload() {
1288 for data in [RefAny::new(()), RefAny::new(0xdead_beef_u64), RefAny::new(())] {
1291 let (update, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
1292 on_tooltip_enter(data.clone(), info)
1293 });
1294 assert_eq!(update, Update::DoNothing);
1295 assert_eq!(opacity_writes(&changes), vec![(2, 1.0)]);
1296 }
1297 }
1298
1299 #[test]
1300 fn repeated_enter_is_idempotent() {
1301 let mut all = Vec::new();
1302 for _ in 0..64 {
1303 let (update, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
1304 on_tooltip_enter(RefAny::new(()), info)
1305 });
1306 assert_eq!(update, Update::DoNothing);
1307 all.push(opacity_writes(&changes));
1308 }
1309 assert!(
1310 all.iter().all(|w| *w == vec![(2, 1.0)]),
1311 "repeated hovers must keep producing the same single write"
1312 );
1313 }
1314
1315 #[test]
1316 fn handlers_never_restyle_the_wrapper_or_the_anchor() {
1317 let dom = Tooltip::new(nested_anchor(2), AzString::from_const_str("tip")).dom();
1318 let styled = StyledDom::create_from_dom(dom);
1319 let wrapper = index_of_class(&styled, WRAPPER_CLASS_NAME).expect("wrapper class missing");
1320 let tip = index_of_class(&styled, TIP_CLASS_NAME).expect("tip class missing");
1321
1322 for handler in [
1323 on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
1324 on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
1325 ] {
1326 let (_, changes) = with_info(Some(styled.clone()), node(wrapper), |info| {
1327 handler(RefAny::new(()), info)
1328 });
1329 let touched: Vec<usize> = css_writes(&changes).into_iter().map(|(i, _)| i).collect();
1330 assert_eq!(
1331 touched,
1332 vec![tip],
1333 "only the tip may be restyled, never the wrapper or the anchor subtree"
1334 );
1335 }
1336 }
1337
1338 #[test]
1339 fn hovering_the_tip_itself_does_nothing() {
1340 let dom = Tooltip::new(Dom::create_div(), AzString::from_const_str("tip")).dom();
1341 let styled = StyledDom::create_from_dom(dom);
1342 let tip = index_of_class(&styled, TIP_CLASS_NAME).expect("tip class missing");
1343
1344 let (update, changes) = with_info(Some(styled), node(tip), |info| {
1345 on_tooltip_enter(RefAny::new(()), info)
1346 });
1347 assert_eq!(update, Update::DoNothing);
1348 assert!(
1349 changes.is_empty(),
1350 "the tip is a leaf text node — it has no tip of its own"
1351 );
1352 }
1353
1354 #[test]
1355 fn every_written_property_is_an_opacity() {
1356 for handler in [
1357 on_tooltip_enter as extern "C" fn(RefAny, CallbackInfo) -> Update,
1358 on_tooltip_leave as extern "C" fn(RefAny, CallbackInfo) -> Update,
1359 ] {
1360 let (_, changes) = with_info(Some(anchor_tip_dom()), node(0), |info| {
1361 handler(RefAny::new(()), info)
1362 });
1363 for (_, props) in css_writes(&changes) {
1364 for p in props {
1365 assert_eq!(
1366 p.get_type(),
1367 opacity_ty(),
1368 "the hover handlers must only toggle opacity"
1369 );
1370 }
1371 }
1372 }
1373 }
1374}