Skip to main content

azul_layout/widgets/
tooltip.rs

1//! Tooltip widget — wraps an arbitrary anchor [`Dom`] and shows a small text
2//! popup near it while the pointer hovers, hiding it again on leave.
3//!
4//! ## Implementation note (CSS-based, see `TODO2` below)
5//!
6//! The drop-down popup path (`open_menu_for_hit_node` / `MenuPopupPosition`) is
7//! built for *menus* — a list of clickable `MenuItem`s — not arbitrary text
8//! shown next to an anchor, and it would also require a live window/hit-test to
9//! verify. This widget therefore takes the simpler, fully-compilable and
10//! self-contained CSS route the recipe allows: the tip is an absolutely-
11//! positioned child of a `position: relative` wrapper, hidden by default
12//! (`opacity: 0`) and revealed on `MouseEnter` / hidden on `MouseLeave` via
13//! `set_css_property`. No user callbacks are needed — the show/hide handlers are
14//! internal.
15//!
16//! TODO2: this is a CSS simplification of a "real" floating popover. The tip is
17//! placed at a fixed offset below the anchor (it does not measure the anchor's
18//! height, flip when near a screen edge, or escape an `overflow: hidden`
19//! ancestor). A future revision could route through the window-popup / menu
20//! popup path for true screen-anchored positioning once that is runtime-
21//! verifiable.
22//!
23//! Key types: [`Tooltip`].
24
25use 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
48// ---- layout (logical px) ----
49/// Fixed vertical offset of the tip below the wrapper's top edge. A
50/// simplification — see the module-level `TODO2`.
51const TIP_OFFSET_Y: isize = 22;
52const TIP_RADIUS: isize = 4;
53
54// ---- colours ----
55/// Tip background (#333333, dark).
56const TIP_BG_COLOR: ColorU = ColorU {
57    r: 51,
58    g: 51,
59    b: 51,
60    a: 240,
61};
62/// Tip text colour (white).
63const 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
73/// Wrapper around the anchor: an inline-block positioning context so the
74/// absolutely-positioned tip is placed relative to it.
75static 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
81/// The tip itself: absolutely positioned, hidden by default (`opacity: 0`).
82static 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    // Preserve the tip on one line so it does not wrap into the anchor's width.
114    CssPropertyWithConditions::simple(CssProperty::WhiteSpace(StyleWhiteSpaceValue::Exact(
115        StyleWhiteSpace::Nowrap,
116    ))),
117    // Hidden until hovered.
118    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
119];
120
121/// A tooltip: an anchor [`Dom`] plus the text shown on hover.
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[repr(C)]
124pub struct Tooltip {
125    /// The element the tooltip is attached to.
126    pub anchor: Dom,
127    /// The text shown in the tip popup.
128    pub text: AzString,
129    /// Style of the positioning wrapper around the anchor.
130    pub wrapper_style: CssPropertyWithConditionsVec,
131    /// Style of the tip popup.
132    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    /// Creates a tooltip wrapping `anchor` that shows `text` on hover.
143    #[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    /// Sets the tip text.
153    #[inline]
154    pub fn set_text(&mut self, text: AzString) {
155        self.text = text;
156    }
157
158    /// Builder-style setter for the tip text.
159    #[inline]
160    #[must_use] pub fn with_text(mut self, text: AzString) -> Self {
161        self.set_text(text);
162        self
163    }
164
165    /// Overrides the tip popup style.
166    #[inline]
167    pub fn set_tip_style(&mut self, style: CssPropertyWithConditionsVec) {
168        self.tip_style = style;
169    }
170
171    /// Builder-style setter for the tip popup style.
172    #[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        // The hover handlers only navigate the DOM (the tip is found relative to
187        // the hovered wrapper), so no per-tooltip state is needed.
188        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            // children: [anchor, tip] — the tip is the anchor's next sibling.
219            .with_children(vec![self.anchor, tip].into())
220    }
221}
222
223/// Returns the tip node (the second child) of the hovered wrapper.
224fn 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
230/// Pointer entered the wrapper → reveal the tip.
231extern "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
238/// Pointer left the wrapper → hide the tip.
239extern "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// `assertions_on_constants`: these are deliberate invariant guards over sibling
254// `const`s in this module. They are const-foldable *today*, which is exactly the
255// point — they must go red the moment someone edits one of those constants into an
256// inconsistent value. Deleting them (clippy's suggestion) would delete the check.
257#[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    // ------------------------------------------------------------------
287    // Helpers — DOM inspection
288    // ------------------------------------------------------------------
289
290    const WRAPPER_CLASS_NAME: &str = "__azul-native-tooltip";
291    const TIP_CLASS_NAME: &str = "__azul-native-tooltip-tip";
292
293    /// True if `node` carries the CSS class `name`.
294    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    /// The text of a `NodeType::Text` node (`None` for any other node type).
303    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    /// The *inline* properties a rendered node carries, in declaration order.
311    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    /// The property *types* of a style vec, in declaration order.
320    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    /// *Every* opacity declared in a style vec, normalized to `0.0..=1.0`, in
329    /// declaration order. More than one entry means the later one silently
330    /// shadows the earlier — the tip would then not be hidden by default.
331    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    /// The `CssPropertyType` of `opacity`, without hard-coding the enum variant.
365    fn opacity_ty() -> CssPropertyType {
366        CssProperty::const_opacity(StyleOpacity::const_new(0)).get_type()
367    }
368
369    /// A style vec built from an owned `Vec` (the shape a *user* override has).
370    fn style_of(props: Vec<CssProperty>) -> CssPropertyWithConditionsVec {
371        props
372            .into_iter()
373            .map(CssPropertyWithConditions::simple)
374            .collect::<Vec<_>>()
375            .into()
376    }
377
378    /// A `Dom` nested `depth` levels deep — a stress input for the recursive
379    /// child bookkeeping `dom()` relies on.
380    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    /// Inputs chosen to break text handling: empty, whitespace-only, interior
389    /// NULs, control characters, astral-plane emoji, ZWJ sequences, stacked
390    /// combining marks, a bidi override, a BOM, and two very large strings.
391    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    // ------------------------------------------------------------------
412    // Helpers — callback harness
413    // ------------------------------------------------------------------
414
415    /// A `DomLayoutResult` with an *empty* layout tree: the hover handlers only
416    /// walk `styled_dom.node_hierarchy`, so no real layout (and no font) is
417    /// needed.
418    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    /// Runs `f` against a `CallbackInfo` whose hit node is `hit`, backed by a
444    /// `LayoutWindow` holding `styled` (or holding nothing at all, when `styled`
445    /// is `None`). Returns `f`'s result plus every recorded `CallbackChange`.
446    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: &current_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    /// Every recorded CSS write, as `(node index, properties)`.
503    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    /// Every recorded opacity write, as `(node index, normalized opacity)`.
516    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    /// Index of the first node carrying `class` in a flattened `StyledDom`.
531    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    /// A three-node styled DOM — `root(0)` with children `anchor(1)` and
541    /// `tip(2)` — i.e. the exact hierarchy `tip_of_wrapper` walks.
542    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    // ------------------------------------------------------------------
557    // Tooltip::new / Default
558    // ------------------------------------------------------------------
559
560    #[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        // The style tables must not vary with the anchor/text — a widget whose
589        // styling depended on its content would be unstyleable.
590        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    // ------------------------------------------------------------------
653    // set_text / with_text
654    // ------------------------------------------------------------------
655
656    #[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    // ------------------------------------------------------------------
707    // set_tip_style / with_tip_style
708    // ------------------------------------------------------------------
709
710    #[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        // The wrapper carries `position: relative`; losing it would make the
728        // absolutely-positioned tip escape to the nearest positioned ancestor.
729        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    // ------------------------------------------------------------------
773    // swap_with_default
774    // ------------------------------------------------------------------
775
776    #[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    // ------------------------------------------------------------------
817    // Static style tables
818    // ------------------------------------------------------------------
819
820    #[test]
821    fn tip_style_starts_hidden_with_exactly_one_opacity_declaration() {
822        // Two opacity declarations would make the last one win and could leave
823        // the tip permanently visible.
824        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    // ------------------------------------------------------------------
912    // Tooltip::dom
913    // ------------------------------------------------------------------
914
915    #[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        // `dom()` mints a fresh marker `RefAny` per call, so the two DOMs are
1020        // deliberately compared field-by-field rather than with `==`.
1021        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        // A too-small `estimated_total_children` makes the arena conversion
1056        // under-allocate and panic on out-of-bounds writes.
1057        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    // ------------------------------------------------------------------
1098    // tip_of_wrapper
1099    // ------------------------------------------------------------------
1100
1101    #[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        // node 1 is a leaf -> no first child -> no tip.
1131        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        // The anchor has a subtree of its own, so the tip is *not* simply
1157        // `hit + 1` — the handler must walk first-child -> next-sibling.
1158        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    // ------------------------------------------------------------------
1175    // on_tooltip_enter / on_tooltip_leave
1176    // ------------------------------------------------------------------
1177
1178    #[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        // Round-trip: what the handler writes on leave must be exactly what the
1215        // stylesheet declares, otherwise the tip would not return to its
1216        // initial rendering.
1217        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        // The handlers are stateless — a foreign (or even empty) payload must
1289        // not change what they do.
1290        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}