Skip to main content

teksilo_widgets/tooltip/
attach.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Rich-tooltip attachment helpers.
5//!
6//! Widgets call [`attach_rich_tooltip`] (or [`attach_rich_tooltip_content`])
7//! from their `build()` to wire a hover-triggered [`RichTooltipWidget`]
8//! onto an anchor. The helper:
9//!
10//! 1. Creates a dormant `RichTooltipWidget` as a child of the current
11//!    build context,
12//! 2. Registers it with the widget tree's tooltip-attachment table
13//!    via [`BuildContext::attach_tooltip`], so hover enter/leave +
14//!    delay timing + overlay show/hide are handled by the same
15//!    machinery the plain `TooltipWidget` already uses.
16//!
17//! This is a thin convenience layer — the full attach lifecycle lives
18//! in `teksilo_core::widget_tree::overlay_impl::attach_tooltip`, which
19//! takes any `content_id` and doesn't care whether it wraps plain text
20//! or rich content. Rich tooltips drop into the existing hover plumbing
21//! without a separate attachment path.
22
23use std::time::Duration;
24
25use teksilo_core::build_context::BuildContext;
26use teksilo_core::overlay::TooltipPlacement;
27use teksilo_core::widget::Widget;
28use teksilo_core::widget_id::WidgetId;
29use teksilo_i18n::LocalizedString;
30
31use crate::tooltip::TooltipWidget;
32use crate::tooltip::composite::CompositeTooltipWidget;
33use crate::tooltip::registry::TooltipContent;
34use crate::tooltip::rich::{DWELL_PROMOTION, RichTooltipWidget};
35
36/// Source resolution for a rich tooltip — either a registry key (the
37/// common path) or an inline [`TooltipContent`] entry (one-offs that
38/// don't belong in the app-wide registry).
39#[derive(Debug, Clone)]
40pub enum RichTooltipSource {
41    /// Resolve against the thread-local
42    /// [`TooltipRegistry`](crate::tooltip::registry::TooltipRegistry)
43    /// at build time using the given key.
44    Key(String),
45    /// Render the given content directly.
46    Content(TooltipContent),
47}
48
49impl<T: Into<String>> From<T> for RichTooltipSource {
50    fn from(value: T) -> Self {
51        RichTooltipSource::Key(value.into())
52    }
53}
54
55/// Attach a rich tooltip to `anchor_id`. Creates a `RichTooltipWidget`
56/// resolving `key` from the registry and wires it into the existing
57/// tooltip-hover machinery.
58///
59/// Typical use inside a widget's `build()`:
60///
61/// ```ignore
62/// let root = ctx.add(/* visible subtree */);
63/// let delay = ctx.theme().motion.tooltip_delay;
64/// attach_rich_tooltip(ctx, root, "save-as-details", delay);
65/// ```
66pub fn attach_rich_tooltip(
67    ctx: &mut BuildContext,
68    anchor_id: WidgetId,
69    key: impl Into<String>,
70    delay: Duration,
71) -> WidgetId {
72    attach_rich_tooltip_with_placement(ctx, anchor_id, key, delay, TooltipPlacement::Below)
73}
74
75/// [`attach_rich_tooltip`] with an explicit [`TooltipPlacement`] — pass
76/// `Side` for anchors stacked vertically (menu items, a vertical tab
77/// strip, list/tree rows) so the tooltip opens beside the anchor.
78pub fn attach_rich_tooltip_with_placement(
79    ctx: &mut BuildContext,
80    anchor_id: WidgetId,
81    key: impl Into<String>,
82    delay: Duration,
83    placement: TooltipPlacement,
84) -> WidgetId {
85    let tooltip = RichTooltipWidget::from_key(key);
86    // Grab the sink BEFORE handing the widget to the arena — after the add we
87    // can't borrow the widget back. The sink is an Rc<Cell<..>> that the tree
88    // updates on show / dismiss and the widget reads from `paint()` to drive
89    // its dwell indicator.
90    let sink = tooltip.shown_at_sink();
91    // Deferred: the body is built the first time a dwell actually matures here
92    // (`WidgetTree::materialize_deferred`), not on every rebuild of the anchor.
93    // A rich tooltip is the most expensive tip there is — its `build` recursively
94    // pre-creates a nested tooltip per `:key` link — so an eagerly-built one on a
95    // row delegate is paid for by every row, on every rebuild.
96    let tooltip_id = ctx.add_deferred_on_demand(tooltip);
97    ctx.attach_tooltip_with_sticky_sink_placement(
98        anchor_id,
99        tooltip_id,
100        delay,
101        Some(DWELL_PROMOTION),
102        sink,
103        placement,
104    );
105    tooltip_id
106}
107
108/// Attach a rich tooltip driven by an inline [`TooltipContent`] entry.
109/// Use this for one-off tooltips that don't live in the central
110/// registry (tests, dynamic content, per-row tips on data-driven
111/// widgets).
112pub fn attach_rich_tooltip_content(
113    ctx: &mut BuildContext,
114    anchor_id: WidgetId,
115    content: TooltipContent,
116    delay: Duration,
117) -> WidgetId {
118    attach_rich_tooltip_content_with_placement(
119        ctx,
120        anchor_id,
121        content,
122        delay,
123        TooltipPlacement::Below,
124    )
125}
126
127/// Attach a **plain** tooltip — first tier, a single line of text.
128///
129/// The one door for plain tooltips, and the reason it exists rather than each
130/// widget doing `ctx.add(TooltipWidget::new(text))` inline: the body is built
131/// the first time a dwell matures over the anchor, not on every rebuild of it.
132/// A plain tip is cheap on its own, but it is attached to nearly every control
133/// in the framework, so on a data view's row delegate the framework was paying
134/// for one per control per row per rebuild — and paying again to tear them all
135/// down, which is where the time actually went.
136pub fn attach_plain_tooltip(
137    ctx: &mut BuildContext,
138    anchor_id: WidgetId,
139    text: impl Into<LocalizedString>,
140    delay: Duration,
141) -> WidgetId {
142    let tooltip_id = ctx.add_deferred_on_demand(TooltipWidget::new(text));
143    ctx.attach_tooltip(anchor_id, tooltip_id, delay);
144    tooltip_id
145}
146
147/// [`attach_plain_tooltip`] with an explicit [`TooltipPlacement`] — what the
148/// widgets that live in a vertical list (menu items, standard items, tab
149/// headers) want, so the tip lands beside the row rather than under it.
150pub fn attach_plain_tooltip_with_placement(
151    ctx: &mut BuildContext,
152    anchor_id: WidgetId,
153    text: impl Into<LocalizedString>,
154    delay: Duration,
155    placement: TooltipPlacement,
156) -> WidgetId {
157    let tooltip_id = ctx.add_deferred_on_demand(TooltipWidget::new(text));
158    ctx.attach_tooltip_with_placement(anchor_id, tooltip_id, delay, placement);
159    tooltip_id
160}
161
162/// [`attach_rich_tooltip_content`] with an explicit [`TooltipPlacement`].
163pub fn attach_rich_tooltip_content_with_placement(
164    ctx: &mut BuildContext,
165    anchor_id: WidgetId,
166    content: TooltipContent,
167    delay: Duration,
168    placement: TooltipPlacement,
169) -> WidgetId {
170    let tooltip = RichTooltipWidget::new(content);
171    let sink = tooltip.shown_at_sink();
172    // Deferred for the same reason as the key-driven path above.
173    let tooltip_id = ctx.add_deferred_on_demand(tooltip);
174    ctx.attach_tooltip_with_sticky_sink_placement(
175        anchor_id,
176        tooltip_id,
177        delay,
178        Some(DWELL_PROMOTION),
179        sink,
180        placement,
181    );
182    tooltip_id
183}
184
185/// Attach a rich tooltip from a [`RichTooltipSource`]. Matches whether
186/// the source is a registry key or inline content and forwards to the
187/// appropriate helper. Convenient for builder methods that accept
188/// `impl Into<RichTooltipSource>` so callers can pass either a bare
189/// `&str` (resolved as a key) or a fully-built `TooltipContent`.
190pub fn attach_rich_tooltip_source(
191    ctx: &mut BuildContext,
192    anchor_id: WidgetId,
193    source: RichTooltipSource,
194    delay: Duration,
195) -> WidgetId {
196    attach_rich_tooltip_source_with_placement(
197        ctx,
198        anchor_id,
199        source,
200        delay,
201        TooltipPlacement::Below,
202    )
203}
204
205/// [`attach_rich_tooltip_source`] with an explicit [`TooltipPlacement`] —
206/// the placement-aware entry point used by widgets that live in a vertical
207/// list (menu items, list/tree rows, activity-rail items) and want `Side`.
208pub fn attach_rich_tooltip_source_with_placement(
209    ctx: &mut BuildContext,
210    anchor_id: WidgetId,
211    source: RichTooltipSource,
212    delay: Duration,
213    placement: TooltipPlacement,
214) -> WidgetId {
215    match source {
216        RichTooltipSource::Key(k) => {
217            attach_rich_tooltip_with_placement(ctx, anchor_id, k, delay, placement)
218        }
219        RichTooltipSource::Content(c) => {
220            attach_rich_tooltip_content_with_placement(ctx, anchor_id, c, delay, placement)
221        }
222    }
223}
224
225/// Attach a composite tooltip — third tier, hosting an arbitrary
226/// `impl Widget + 'static` body. Wires the same dwell-to-sticky
227/// machinery rich tooltips use, so the surface promotes to a
228/// `Role::Dialog` after the user dwells for `DWELL_PROMOTION`.
229pub fn attach_composite_tooltip(
230    ctx: &mut BuildContext,
231    anchor_id: WidgetId,
232    content: impl Widget + 'static,
233    delay: Duration,
234) -> WidgetId {
235    attach_composite_tooltip_boxed(ctx, anchor_id, Box::new(content), delay)
236}
237
238/// Variant of [`attach_composite_tooltip`] that takes an already-boxed
239/// body. Used by per-widget `.composite_tooltip(...)` setters that
240/// store `Box<dyn Widget>` so the user-supplied content can survive
241/// across the borrow boundary into `build()`.
242pub fn attach_composite_tooltip_boxed(
243    ctx: &mut BuildContext,
244    anchor_id: WidgetId,
245    content: Box<dyn Widget>,
246    delay: Duration,
247) -> WidgetId {
248    attach_composite_tooltip_boxed_with_placement(
249        ctx,
250        anchor_id,
251        content,
252        delay,
253        TooltipPlacement::Below,
254    )
255}
256
257/// Attach an already-built [`CompositeTooltipWidget`], honouring its own
258/// [`sticky`](CompositeTooltipWidget::sticky) setting.
259///
260/// The general primitive the other composite helpers lower to. Reach for it
261/// when the body is read-only and should not offer dwell promotion, or when
262/// the surface needs an accessible label — both of which are settings on the
263/// widget, and neither of which a helper taking a bare `Box<dyn Widget>` can
264/// express.
265pub fn attach_composite_tooltip_widget_with_placement(
266    ctx: &mut BuildContext,
267    anchor_id: WidgetId,
268    tooltip: CompositeTooltipWidget,
269    delay: Duration,
270    placement: TooltipPlacement,
271) -> WidgetId {
272    // A surface with no promotion registers no dwell window. That is what makes
273    // it behave like a plain tooltip: pointer-leave retires it, focus does not
274    // surface it, and it never becomes a `Dialog`.
275    let sticky_after = tooltip.sticky_enabled().then_some(DWELL_PROMOTION);
276    let sink = tooltip.shown_at_sink();
277    // Built the first time the pointer actually dwells here, not on every
278    // rebuild of the anchor. Tooltips are the most widely attached thing in the
279    // framework — a table cell with one pays for its body on every rebuild of
280    // the row — and the tree forces this host just before the dwell matures
281    // (`WidgetTree::materialize_deferred`).
282    let tooltip_id = ctx.add_detached_deferred_on_demand(tooltip);
283    ctx.attach_tooltip_with_sticky_sink_placement(
284        anchor_id,
285        tooltip_id,
286        delay,
287        sticky_after,
288        sink,
289        placement,
290    );
291    tooltip_id
292}
293
294pub fn attach_composite_tooltip_boxed_with_placement(
295    ctx: &mut BuildContext,
296    anchor_id: WidgetId,
297    content: Box<dyn Widget>,
298    delay: Duration,
299    placement: TooltipPlacement,
300) -> WidgetId {
301    attach_composite_tooltip_widget_with_placement(
302        ctx,
303        anchor_id,
304        CompositeTooltipWidget::new().content_boxed(content),
305        delay,
306        placement,
307    )
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::button::Button;
314    use crate::menu_item::MenuItem;
315    use crate::menu_list::MenuList;
316    use crate::primitives::VStack;
317    use crate::tooltip::TooltipWidget;
318    use crate::tooltip::registry::{
319        _reset_tooltip_registry, TooltipContent, install_tooltip_registry,
320    };
321    use std::cell::RefCell;
322    use std::rc::Rc;
323    use teksilo_canvas::{MockTextBackend, SizeProposal};
324    use teksilo_core::event::{Key, Modifiers};
325    use teksilo_core::signal::Signal;
326    use teksilo_core::widget_tree::WidgetTree;
327    use teksilo_i18n::lit;
328
329    fn tree_with_backend() -> WidgetTree {
330        WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
331    }
332
333    /// **A plain tooltip's text must reach the control it describes.**
334    ///
335    /// Plain tooltips are deliberately not shown on focus (see `docs/tooltips.md`,
336    /// "Keyboard / a11y promotion"), and the whole of what makes that acceptable is
337    /// the other half of the bargain: the text is copied onto the anchoring control
338    /// as its accessible description, which is the W3C pattern for a supplementary
339    /// hint. Landing anywhere else leaves the tier reaching a pointer and nothing
340    /// else.
341    ///
342    /// Asserted on the node an assistive technology actually lands on -- the one
343    /// carrying the control's own role -- not merely "somewhere in the subtree",
344    /// because a description on an unnamed box beside the control is a description
345    /// nobody hears. Composing controls keep their role and focus on an outer node
346    /// while anchoring the tooltip on an inner body root, which is exactly the
347    /// arrangement this has to survive.
348    #[test]
349    fn a_plain_tooltips_text_lands_on_the_control_it_describes() {
350        fn described(
351            update: &teksilo_core::accesskit::TreeUpdate,
352            id: teksilo_core::WidgetId,
353        ) -> Option<String> {
354            let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
355            update
356                .nodes
357                .iter()
358                .find(|(node_id, _)| *node_id == nid)
359                .and_then(|(_, n)| n.description().map(str::to_owned))
360        }
361
362        // Button: role and focus on the outer node, tooltip on the style body root.
363        let mut tree = tree_with_backend();
364        let button = tree.add(Button::new(lit!("Export")).tooltip(lit!("Save a copy")));
365        tree.layout(SizeProposal::exact(300.0, 40.0));
366        let update = tree.sync_accessibility();
367        assert_eq!(
368            described(&update, button).as_deref(),
369            Some("Save a copy"),
370            "a Button's hint must be on the Button, not on the box inside it"
371        );
372
373        // Toggle: same shape, with the tooltip on the switch+label HStack.
374        let mut tree = tree_with_backend();
375        let toggle = tree.add(
376            crate::toggle::Toggle::new(teksilo_core::signal::Signal::new(true))
377                .label(lit!("Comments"))
378                .tooltip(lit!("Where a note is attached")),
379        );
380        tree.layout(SizeProposal::exact(300.0, 40.0));
381        let update = tree.sync_accessibility();
382        assert_eq!(
383            described(&update, toggle).as_deref(),
384            Some("Where a note is attached"),
385            "a Toggle's hint must be on the Toggle"
386        );
387
388        // And on exactly one node. A description repeated down a nest is announced
389        // twice, which is worse than announcing it once in the wrong place.
390        let carriers = update
391            .nodes
392            .iter()
393            .filter(|(_, n)| n.description() == Some("Where a note is attached"))
394            .count();
395        assert_eq!(carriers, 1, "exactly one node may carry the hint");
396    }
397
398    /// **A tooltip nobody has hovered is never built.**
399    ///
400    /// Not built-and-parked — not built. This is the whole reason the three
401    /// attach tiers go through `add_*deferred_on_demand`: a tooltip is the most
402    /// widely attached thing in the framework, so on a data view's row delegate
403    /// the eager form charged every row for a body no one had asked to see, on
404    /// every rebuild — and charged again to tear them all down. Measured on
405    /// Skribisto's Overview before this: 29 rows carried 1,305 tooltip widgets
406    /// inside a 22,737-node subtree, and one arrow-key press spent 5.3 s
407    /// destroying it against 0.06 s rebuilding it.
408    #[test]
409    fn an_unhovered_tooltip_body_is_never_built() {
410        /// Counts its own builds, so the test can tell "not shown" from
411        /// "not built".
412        #[derive(Debug)]
413        struct Counted {
414            builds: Signal<u32>,
415        }
416
417        impl teksilo_core::widget::Widget for Counted {
418            fn build(
419                &mut self,
420                _ctx: &mut teksilo_core::build_context::BuildContext,
421            ) -> Vec<WidgetId> {
422                self.builds.set(self.builds.get() + 1);
423                Vec::new()
424            }
425
426            fn layout_response(
427                &self,
428                proposal: SizeProposal,
429                _ctx: &teksilo_core::widget::LayoutContext,
430            ) -> teksilo_core::widget::LayoutResponse {
431                proposal.resolve(40.0, 20.0).into()
432            }
433        }
434
435        /// An anchor that attaches a composite tooltip carrying `Counted`.
436        #[derive(Debug)]
437        struct Anchor {
438            builds: Signal<u32>,
439            anchor_builds: Signal<u32>,
440            root: Option<WidgetId>,
441        }
442
443        impl teksilo_core::widget::Widget for Anchor {
444            fn build(
445                &mut self,
446                ctx: &mut teksilo_core::build_context::BuildContext,
447            ) -> Vec<WidgetId> {
448                self.anchor_builds.set(self.anchor_builds.get() + 1);
449                let root = ctx.add(Button::new(lit!("row")));
450                self.root = Some(root);
451                attach_composite_tooltip(
452                    ctx,
453                    root,
454                    Counted {
455                        builds: self.builds.clone(),
456                    },
457                    Duration::from_millis(10),
458                );
459                vec![root]
460            }
461
462            fn layout_response(
463                &self,
464                proposal: SizeProposal,
465                ctx: &teksilo_core::widget::LayoutContext,
466            ) -> teksilo_core::widget::LayoutResponse {
467                self.root
468                    .and_then(|id| ctx.child_size(id, proposal))
469                    .unwrap_or(teksilo_canvas::Size::new(0.0, 0.0))
470                    .into()
471            }
472        }
473
474        let builds = Signal::new(0);
475        let anchor_builds = Signal::new(0);
476        let mut tree = WidgetTree::new();
477        let id = tree.add(Anchor {
478            builds: builds.clone(),
479            anchor_builds: anchor_builds.clone(),
480            root: None,
481        });
482        tree.layout(SizeProposal::exact(300.0, 40.0));
483        assert_eq!(builds.get(), 0, "a tooltip body was built without a dwell");
484
485        // And rebuilding the anchor — what a row delegate does constantly —
486        // still does not build it. This is the case the cost was in.
487        for _ in 0..5 {
488            tree.arena_mark_needs_rebuild_for_testing(id);
489            tree.layout(SizeProposal::exact(300.0, 40.0));
490        }
491        assert!(
492            anchor_builds.get() >= 5,
493            "the anchor must really have rebuilt; got {}",
494            anchor_builds.get()
495        );
496        assert_eq!(
497            builds.get(),
498            0,
499            "the anchor rebuilt {} times and dragged its unhovered tooltip along",
500            anchor_builds.get()
501        );
502    }
503
504    /// **A build that attaches many tooltips claims none of them.**
505    ///
506    /// The owner a tooltip records is the widget that was building, and one
507    /// build can attach a great many: a list body pane attaches one per visible
508    /// row, every one of them naming the pane. Granting that would put one
509    /// row's text on the pane and lose every other row's outright -- a fix that
510    /// destroys more than it repairs, on the widget where most tooltips in a
511    /// real application actually live.
512    ///
513    /// So a contested claim is no claim, and each tooltip stays on its own
514    /// anchor, which is where it already was.
515    #[test]
516    fn tooltips_attached_to_many_children_in_one_build_stay_on_their_own_rows() {
517        /// A pane shaped like a virtualized row host: several row widgets, a
518        /// tooltip on each, all attached from this one build.
519        #[derive(Debug)]
520        struct RowPane {
521            rows: Vec<WidgetId>,
522        }
523
524        impl teksilo_core::widget::Widget for RowPane {
525            fn build(
526                &mut self,
527                ctx: &mut teksilo_core::build_context::BuildContext,
528            ) -> Vec<WidgetId> {
529                self.rows.clear();
530                for label in ["Alpha", "Beta"] {
531                    let row = ctx.add(Button::new(lit!(String::from(label))));
532                    let tip = ctx.add(TooltipWidget::new(lit!(String::from("about ") + label)));
533                    ctx.attach_tooltip(row, tip, Duration::from_millis(10));
534                    self.rows.push(row);
535                }
536                self.rows.clone()
537            }
538
539            fn layout_response(
540                &self,
541                proposal: teksilo_canvas::SizeProposal,
542                _ctx: &teksilo_core::widget::LayoutContext,
543            ) -> teksilo_core::widget::LayoutResponse {
544                proposal.resolve(200.0, 40.0).into()
545            }
546
547            fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
548                // Role::Group, like ListBodyPane: emphatically not a
549                // presentational container, so nothing else disqualifies it
550                // from claiming. Only the contest does.
551                builder.set_role(teksilo_core::accesskit::Role::Group);
552            }
553        }
554
555        let mut tree = tree_with_backend();
556        let pane = tree.add(RowPane { rows: Vec::new() });
557        tree.layout(SizeProposal::exact(200.0, 40.0));
558        let update = tree.sync_accessibility();
559
560        let described: Vec<String> = update
561            .nodes
562            .iter()
563            .filter_map(|(_, n)| n.description().map(str::to_owned))
564            .collect();
565        assert_eq!(
566            described.len(),
567            2,
568            "both rows keep their own hint: {described:?}"
569        );
570        assert!(described.iter().any(|d| d == "about Alpha"));
571        assert!(described.iter().any(|d| d == "about Beta"));
572
573        // And emphatically not on the pane, which claimed both and got neither.
574        let pane_node = update
575            .nodes
576            .iter()
577            .find(|(nid, _)| *nid == teksilo_core::accessibility::widget_id_to_node_id(pane))
578            .map(|(_, n)| n);
579        assert_eq!(
580            pane_node.and_then(|n| n.description()),
581            None,
582            "a pane that claimed one hint per row must be given none of them"
583        );
584    }
585
586    /// **A control's own words about itself are not overwritten by a tooltip's.**
587    ///
588    /// Both land in the one scalar AccessKit description field, and until the
589    /// owner rule existed they could not collide -- the explicit one went on
590    /// the control, the tooltip's went on the inner box. Now they aim at the
591    /// same node, so which wins has to be decided rather than discovered.
592    /// `MenuItem::trailing_hint` is the case that made this reachable.
593    ///
594    /// The specific beats the supplementary, and the tooltip falls back to its
595    /// anchor -- exactly where it sat before any of this, so a control that
596    /// describes itself is no worse off than it was.
597    #[test]
598    fn a_widgets_own_description_is_not_overwritten_by_its_tooltips() {
599        #[derive(Debug)]
600        struct SelfDescribing {
601            inner: Option<WidgetId>,
602        }
603
604        impl teksilo_core::widget::Widget for SelfDescribing {
605            fn build(
606                &mut self,
607                ctx: &mut teksilo_core::build_context::BuildContext,
608            ) -> Vec<WidgetId> {
609                let body = ctx.add(Button::new(lit!("Save")));
610                let tip = ctx.add(TooltipWidget::new(lit!("supplementary")));
611                ctx.attach_tooltip(body, tip, Duration::from_millis(10));
612                self.inner = Some(body);
613                vec![body]
614            }
615
616            fn layout_response(
617                &self,
618                proposal: teksilo_canvas::SizeProposal,
619                _ctx: &teksilo_core::widget::LayoutContext,
620            ) -> teksilo_core::widget::LayoutResponse {
621                proposal.resolve(120.0, 30.0).into()
622            }
623
624            fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
625                builder.set_role(teksilo_core::accesskit::Role::Button);
626                builder.set_name("Save");
627                builder.set_description("Ctrl+S");
628            }
629        }
630
631        let mut tree = tree_with_backend();
632        let id = tree.add(SelfDescribing { inner: None });
633        tree.layout(SizeProposal::exact(120.0, 30.0));
634        let update = tree.sync_accessibility();
635
636        let own = update
637            .nodes
638            .iter()
639            .find(|(nid, _)| *nid == teksilo_core::accessibility::widget_id_to_node_id(id))
640            .map(|(_, n)| n)
641            .expect("the control emits a node");
642        assert_eq!(
643            own.description(),
644            Some("Ctrl+S"),
645            "the widget's own description must survive its tooltip"
646        );
647        assert_eq!(
648            update
649                .nodes
650                .iter()
651                .filter(|(_, n)| n.description() == Some("supplementary"))
652                .count(),
653            1,
654            "and the tooltip's text is still emitted, on its anchor as before"
655        );
656    }
657
658    /// **A shown tooltip does not eat Escape.**
659    ///
660    /// It is dismissed by the press — WCAG 1.4.13 asks for exactly that — but
661    /// the keystroke carries on to the focused widget, which is the half that
662    /// was missing. A tooltip is up far more often than anyone realises: the
663    /// pointer rests wherever it last clicked, the tip dwells in behind it, and
664    /// the next Escape goes to the tip instead of to the rename / dialog /
665    /// menu the user meant to cancel. It works on the *second* press, so it
666    /// reads as "Escape does nothing" rather than as a tooltip bug.
667    #[test]
668    fn escape_dismisses_a_tooltip_and_still_reaches_the_focused_widget() {
669        use std::cell::Cell;
670        use std::rc::Rc;
671        use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
672        use teksilo_core::widget_builder::WidgetBuilder;
673
674        let seen: Rc<Cell<usize>> = Rc::new(Cell::new(0));
675        let counter = seen.clone();
676
677        let mut tree = tree_with_backend();
678        let btn = tree.add(
679            Button::new(lit!("Save As"))
680                .tooltip(lit!("Save the current file under a new name"))
681                .on_key(move |ev, _ctx| {
682                    if let WidgetEvent::KeyDown {
683                        key: Key::Escape, ..
684                    } = ev
685                    {
686                        counter.set(counter.get() + 1);
687                        return EventResponse::Handled;
688                    }
689                    EventResponse::Ignored
690                }),
691        );
692        tree.layout(SizeProposal::exact(400.0, 200.0));
693        tree.focus(btn);
694
695        // Dwell until the tip is up — the ordinary state of a pointer that has
696        // stopped moving.
697        tree.pointer_move(tree.bounds(btn).center());
698        tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
699        assert_eq!(
700            tree.active_overlays().len(),
701            1,
702            "the tooltip should be showing"
703        );
704
705        tree.press_key(Key::Escape, Modifiers::NONE);
706
707        assert!(
708            tree.active_overlays().is_empty(),
709            "Escape must still dismiss the tooltip (WCAG 1.4.13)"
710        );
711        assert_eq!(
712            seen.get(),
713            1,
714            "the focused widget never saw Escape — the tooltip swallowed it"
715        );
716    }
717
718    #[test]
719    fn button_rich_tooltip_appears_after_hover_delay() {
720        _reset_tooltip_registry();
721        install_tooltip_registry(vec![TooltipContent::new(
722            "save-as",
723            lit!("Save the current file under a new name"),
724        )]);
725
726        let mut tree = tree_with_backend();
727        let btn = tree.add(Button::new(lit!("Save As")).rich_tooltip("save-as"));
728        tree.layout(SizeProposal::exact(400.0, 200.0));
729
730        // No tooltip visible before hover.
731        assert!(tree.active_overlays().is_empty());
732
733        tree.pointer_move(tree.bounds(btn).center());
734        assert!(
735            tree.active_overlays().is_empty(),
736            "tooltip should not appear instantly — waits for delay"
737        );
738
739        tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
740
741        assert_eq!(
742            tree.active_overlays().len(),
743            1,
744            "rich tooltip should have appeared after the hover delay"
745        );
746
747        _reset_tooltip_registry();
748    }
749
750    #[test]
751    fn button_rich_tooltip_overrides_plain_tooltip() {
752        _reset_tooltip_registry();
753        install_tooltip_registry(vec![TooltipContent::new("help", lit!("Help body"))]);
754
755        let mut tree = tree_with_backend();
756        // Plain set first, then rich: rich should win (latest setter
757        // clears the other field).
758        let btn = tree.add(
759            Button::new(lit!("Help"))
760                .tooltip(lit!("stale plain text"))
761                .rich_tooltip("help"),
762        );
763        tree.layout(SizeProposal::exact(400.0, 200.0));
764        tree.pointer_move(tree.bounds(btn).center());
765        tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
766
767        assert_eq!(tree.active_overlays().len(), 1);
768        // The stale plain text must NOT be reachable — the rich tooltip
769        // supplanted it entirely.
770        assert!(
771            tree.find_by_label("stale plain text").is_none(),
772            "plain tooltip text should have been cleared by .rich_tooltip(...)"
773        );
774
775        _reset_tooltip_registry();
776    }
777
778    #[test]
779    fn rich_tooltip_shows_on_keyboard_focus_once_focus_rests() {
780        _reset_tooltip_registry();
781        install_tooltip_registry(vec![TooltipContent::new(
782            "focus-key",
783            lit!("Focus-shown body"),
784        )]);
785
786        let mut tree = tree_with_backend();
787        let btn = tree.add(Button::new(lit!("Focus me")).rich_tooltip("focus-key"));
788        tree.layout(SizeProposal::exact(400.0, 200.0));
789
790        assert!(tree.active_overlays().is_empty());
791
792        // Keyboard focus, no hover. Focus arms the same delay the pointer
793        // arms — a tip that appeared on arrival strobed across a Tab sweep.
794        tree.focus(btn);
795        assert!(
796            tree.active_overlays().is_empty(),
797            "focus arriving arms the delay; it does not show on arrival"
798        );
799        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
800
801        assert_eq!(
802            tree.active_overlays().len(),
803            1,
804            "rich tooltip appears once keyboard focus has rested for the delay"
805        );
806
807        _reset_tooltip_registry();
808    }
809
810    #[test]
811    fn focus_promoted_tooltip_dismisses_when_focus_leaves_scope() {
812        _reset_tooltip_registry();
813        install_tooltip_registry(vec![TooltipContent::new("leave-key", lit!("Goes away"))]);
814
815        let mut tree = tree_with_backend();
816        let btn = tree.add(Button::new(lit!("Anchor")).rich_tooltip("leave-key"));
817        let other = tree.add(Button::new(lit!("Elsewhere")));
818        tree.layout(SizeProposal::exact(400.0, 200.0));
819
820        tree.focus(btn);
821        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
822        assert_eq!(tree.active_overlays().len(), 1);
823
824        // Moving focus to an unrelated widget dismisses the
825        // focus-promoted tooltip (prevents sticky accumulation as the
826        // user Tabs through a form).
827        tree.focus(other);
828        assert!(
829            tree.active_overlays().is_empty(),
830            "focus-promoted sticky tooltip should dismiss when focus moves outside its scope"
831        );
832
833        _reset_tooltip_registry();
834    }
835
836    #[test]
837    fn button_plain_tooltip_appears_after_hover_delay() {
838        let mut tree = tree_with_backend();
839        let btn = tree.add(Button::new(lit!("Save")).tooltip(lit!("Save the document")));
840        tree.layout(SizeProposal::exact(400.0, 200.0));
841
842        assert!(tree.active_overlays().is_empty());
843        tree.pointer_move(tree.bounds(btn).center());
844        assert!(
845            tree.active_overlays().is_empty(),
846            "plain tooltip should not appear instantly — waits for delay"
847        );
848        // Plain tooltip uses theme `tooltip_delay` (500 ms default).
849        tree.advance_time(Duration::from_millis(550));
850        assert_eq!(
851            tree.active_overlays().len(),
852            1,
853            "plain tooltip should have appeared after the hover delay"
854        );
855    }
856
857    #[test]
858    fn inline_content_tooltip_attaches_without_registry_key() {
859        _reset_tooltip_registry();
860        // No install_tooltip_registry — we rely on inline content.
861        let mut tree = tree_with_backend();
862        let content = TooltipContent::new("inline-only", lit!("Inline content"));
863        let btn = tree.add(Button::new(lit!("Go")).rich_tooltip_content(content));
864        tree.layout(SizeProposal::exact(400.0, 200.0));
865        tree.pointer_move(tree.bounds(btn).center());
866        tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
867
868        assert_eq!(tree.active_overlays().len(), 1);
869
870        _reset_tooltip_registry();
871    }
872
873    // ---- Part A: the "wall of tooltips" fix ------------------------------
874
875    #[test]
876    fn menu_container_focus_does_not_fan_out_item_tooltips() {
877        // The reported bug: opening a context menu focuses the whole
878        // `MenuList` panel, which — before the fix — promoted EVERY item's
879        // rich tooltip at once (a wall). The container-fan-out guard
880        // (`reverse.len() == 1`) suppresses it.
881        _reset_tooltip_registry();
882        install_tooltip_registry(vec![
883            TooltipContent::new("a", lit!("Tip A")),
884            TooltipContent::new("b", lit!("Tip B")),
885            TooltipContent::new("c", lit!("Tip C")),
886        ]);
887
888        let mut tree = tree_with_backend();
889        let menu = tree.add(
890            MenuList::new()
891                .item(MenuItem::new(lit!("A")).rich_tooltip("a"))
892                .item(MenuItem::new(lit!("B")).rich_tooltip("b"))
893                .item(MenuItem::new(lit!("C")).rich_tooltip("c")),
894        );
895        tree.layout(SizeProposal::exact(400.0, 300.0));
896
897        tree.focus(menu);
898        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
899        assert!(
900            tree.active_overlays().is_empty(),
901            "focusing the menu container must not fan out item tooltips (the wall)"
902        );
903
904        _reset_tooltip_registry();
905    }
906
907    #[test]
908    fn self_anchored_focusable_tooltip_shows_exactly_one_overlay() {
909        // A widget that anchors its own sticky tooltip to its *own* id
910        // (the `TabHeader` / `ColorSwatch` shape) matches BOTH the direct and
911        // reverse predicates, because `is_descendant_of` is reflexive. The
912        // mutually-exclusive `if / else if` routing must promote it exactly
913        // once — two independent filters would double-`show_overlay` and leak
914        // an orphaned overlay.
915        let mut tree = tree_with_backend();
916        let anchor = tree.add(Button::new(lit!("Self")));
917        let content = tree.add(TooltipWidget::new(lit!("Tip")));
918        tree.attach_tooltip_with_sticky(
919            anchor,
920            content,
921            Duration::from_millis(200),
922            Some(Duration::from_secs(2)),
923        );
924        tree.layout(SizeProposal::exact(400.0, 200.0));
925
926        tree.focus(anchor);
927        tree.advance_time(Duration::from_millis(250));
928        assert_eq!(
929            tree.active_overlays().len(),
930            1,
931            "self-anchored focus shows exactly one overlay (no reflexive dup)"
932        );
933    }
934
935    #[test]
936    fn single_button_rich_tooltip_still_shows_on_focus() {
937        // Regression guard for the composing-widget case: `Button` keeps focus
938        // on its outer node but anchors the tooltip on an inner root (the sole
939        // reverse match). It must still promote on focus after the fix.
940        _reset_tooltip_registry();
941        install_tooltip_registry(vec![TooltipContent::new("k", lit!("Body"))]);
942        let mut tree = tree_with_backend();
943        let btn = tree.add(Button::new(lit!("Focus me")).rich_tooltip("k"));
944        tree.layout(SizeProposal::exact(400.0, 200.0));
945
946        tree.focus(btn);
947        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
948        assert_eq!(
949            tree.active_overlays().len(),
950            1,
951            "a single composing control still auto-shows its rich tooltip on focus"
952        );
953        _reset_tooltip_registry();
954    }
955
956    #[test]
957    fn segmented_control_focus_does_not_fan_out_segment_tooltips() {
958        // A `SegmentedControl` is a single focus stop owning many segment
959        // tooltips (the segments anchor to their own ids, and the control is
960        // their focusable ancestor) — the same fan-out shape as a menu. The
961        // `reverse.len() == 1` guard protects it for free.
962        _reset_tooltip_registry();
963        install_tooltip_registry(vec![
964            TooltipContent::new("s0", lit!("Seg 0")),
965            TooltipContent::new("s1", lit!("Seg 1")),
966        ]);
967        let mut tree = tree_with_backend();
968        let selected = teksilo_core::signal::Signal::new(None);
969        let sc = tree.add(
970            crate::segmented_control::SegmentedControl::new(selected)
971                .segment(crate::segmented_control::Segment::new(lit!("A")).rich_tooltip("s0"))
972                .segment(crate::segmented_control::Segment::new(lit!("B")).rich_tooltip("s1")),
973        );
974        tree.layout(SizeProposal::exact(400.0, 200.0));
975
976        tree.focus(sc);
977        assert!(
978            tree.active_overlays().is_empty(),
979            "focusing a SegmentedControl must not fan out its segment tooltips"
980        );
981        // Ripen the delay too, so this proves the fan-out guard rather than
982        // merely that focus no longer shows a tip on arrival.
983        tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
984        assert!(
985            tree.active_overlays().is_empty(),
986            "…and still none once the delay has elapsed"
987        );
988        _reset_tooltip_registry();
989    }
990
991    // ---- Part B: side placement ------------------------------------------
992
993    #[test]
994    fn side_placement_opens_to_the_trailing_side() {
995        let mut tree = tree_with_backend();
996        // Anchor nested at top-leading of a VStack so it stays small (sized to
997        // content, not stretched) with room to its trailing side and below.
998        let anchor = tree.add(Button::new(lit!("Anchor")));
999        let content = tree.add(TooltipWidget::new(lit!("Tip")));
1000        tree.attach_tooltip_with_placement(
1001            anchor,
1002            content,
1003            Duration::from_millis(200),
1004            TooltipPlacement::Side,
1005        );
1006        let _root = tree.add(VStack::new().child(anchor));
1007        tree.layout(SizeProposal::exact(600.0, 400.0));
1008        tree.pointer_move(tree.bounds(anchor).center());
1009        tree.advance_time(Duration::from_millis(250));
1010        // Re-layout so the overlay positioner runs on the freshly-shown tooltip.
1011        tree.layout(SizeProposal::exact(600.0, 400.0));
1012
1013        let a = tree.bounds(anchor);
1014        let t = tree
1015            .overlay_manager()
1016            .bounds_for_content(content)
1017            .expect("Side tooltip overlay shown");
1018        assert!(
1019            t.x >= a.x + a.width,
1020            "Side tooltip opens to the trailing side: t.x {} >= anchor right {}",
1021            t.x,
1022            a.x + a.width
1023        );
1024        assert!(
1025            t.y < a.y + a.height,
1026            "Side tooltip is aligned to the anchor top, not below it"
1027        );
1028    }
1029
1030    #[test]
1031    fn below_placement_opens_under_the_anchor() {
1032        let mut tree = tree_with_backend();
1033        let anchor = tree.add(Button::new(lit!("Anchor")));
1034        let content = tree.add(TooltipWidget::new(lit!("Tip")));
1035        // Default placement is Below.
1036        tree.attach_tooltip(anchor, content, Duration::from_millis(200));
1037        let _root = tree.add(VStack::new().child(anchor));
1038        tree.layout(SizeProposal::exact(600.0, 400.0));
1039        tree.pointer_move(tree.bounds(anchor).center());
1040        tree.advance_time(Duration::from_millis(250));
1041        tree.layout(SizeProposal::exact(600.0, 400.0));
1042
1043        let a = tree.bounds(anchor);
1044        let t = tree
1045            .overlay_manager()
1046            .bounds_for_content(content)
1047            .expect("Below tooltip overlay shown");
1048        assert!(
1049            t.y >= a.y + a.height,
1050            "Below tooltip opens under the anchor: t.y {} >= anchor bottom {}",
1051            t.y,
1052            a.y + a.height
1053        );
1054    }
1055
1056    // ---- Part C: keyboard reachability of menu item tooltips -------------
1057
1058    #[test]
1059    fn keyboard_menu_navigation_surfaces_highlighted_item_tooltip() {
1060        _reset_tooltip_registry();
1061        install_tooltip_registry(vec![
1062            TooltipContent::new("a", lit!("Tip A")),
1063            TooltipContent::new("b", lit!("Tip B")),
1064        ]);
1065
1066        let mut tree = tree_with_backend();
1067        // Third item has NO tooltip — highlighting it must dismiss the prior
1068        // one and show nothing.
1069        let menu = tree.add(
1070            MenuList::new()
1071                .item(MenuItem::new(lit!("A")).rich_tooltip("a"))
1072                .item(MenuItem::new(lit!("B")).rich_tooltip("b"))
1073                .item(MenuItem::new(lit!("C"))),
1074        );
1075        tree.layout(SizeProposal::exact(400.0, 300.0));
1076
1077        // Focus the menu panel (as the open path does): no wall.
1078        tree.focus(menu);
1079        assert!(
1080            tree.active_overlays().is_empty(),
1081            "no tooltip on menu focus (Part A)"
1082        );
1083
1084        // Arrow-key highlight surfaces exactly the highlighted item's tooltip.
1085        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1086        assert_eq!(
1087            tree.active_overlays().len(),
1088            1,
1089            "ArrowDown surfaces the highlighted item's tooltip (Part C)"
1090        );
1091
1092        // Moving the highlight dismisses the previous tooltip and shows the
1093        // next — still exactly one, never a growing stack.
1094        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1095        assert_eq!(
1096            tree.active_overlays().len(),
1097            1,
1098            "moving the highlight replaces the tooltip (still exactly one)"
1099        );
1100
1101        // Highlighting a tooltip-less item dismisses the prior tooltip and
1102        // shows nothing.
1103        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1104        assert!(
1105            tree.active_overlays().is_empty(),
1106            "highlighting a tooltip-less item clears the previous tooltip"
1107        );
1108
1109        _reset_tooltip_registry();
1110    }
1111
1112    // ---- dwell indicator: continuous update while the pointer is still -----
1113
1114    #[test]
1115    fn dwelling_tooltip_wake_deadline_is_due_at_its_wake() {
1116        // Regression for "the dwell indicator only updates when the mouse
1117        // moves": the 500 ms dwell wake deadline must be rounded off the LAST
1118        // RENDERED FRAME (`last_frame_time`), not off `Instant::now()`.
1119        //
1120        // The app's `request_redraw_due` only redraws a window whose
1121        // `next_timer_deadline() <= now`. A `now`-rounded deadline rolls to the
1122        // NEXT (future) step the instant its own wake fires, so `<= now` never
1123        // holds, the window is never redrawn, and the dwell freezes until an
1124        // unrelated input event nudges the loop. Rounding off `last_frame_time`
1125        // keeps the deadline `<= now` at its wake — one redraw per boundary.
1126        //
1127        // Here: show the tooltip, freeze `last_frame_time` at the show render,
1128        // then let real time cross the first 500 ms boundary WITHOUT another
1129        // render (the stationary-pointer case) and assert the deadline is due.
1130        _reset_tooltip_registry();
1131        install_tooltip_registry(vec![TooltipContent::new("k", lit!("Body"))]);
1132        let mut tree = tree_with_backend();
1133        // Reduced motion removes the fade animation, so `next_timer_deadline`
1134        // below reflects ONLY the dwell wake — not a fade deadline that would
1135        // pass the assert regardless of the dwell fix.
1136        tree.set_accessibility_preferences(false, true, 1.0);
1137        let btn = tree.add(Button::new(lit!("Hover")).rich_tooltip("k"));
1138        tree.layout(SizeProposal::exact(400.0, 200.0));
1139        tree.pointer_move(tree.bounds(btn).center());
1140        tree.advance_time(Duration::from_millis(550)); // past the hover delay → shown
1141        // A render pins `last_frame_time` at ~= the show instant.
1142        tree.layout(SizeProposal::exact(400.0, 200.0));
1143        assert_eq!(tree.active_overlays().len(), 1, "rich tooltip shown");
1144
1145        // Real time crosses the first 500 ms step boundary with NO further
1146        // render (last_frame_time stays frozen) — exactly what a still pointer
1147        // gives the event loop.
1148        std::thread::sleep(Duration::from_millis(600));
1149
1150        let deadline = tree
1151            .next_timer_deadline()
1152            .expect("a dwelling tooltip must schedule a wake deadline");
1153        assert!(
1154            deadline <= std::time::Instant::now(),
1155            "the dwell wake deadline must be DUE at its own wake (pinned to \
1156             last_frame_time); a still-future deadline is the freeze bug"
1157        );
1158
1159        _reset_tooltip_registry();
1160    }
1161
1162    #[test]
1163    fn plain_tooltip_schedules_no_dwell_wake() {
1164        // A plain (non-sticky) tooltip has no dwell timer, so once shown it must
1165        // NOT keep scheduling wake deadlines — the dwell wake is scoped to
1166        // rich/composite tooltips only. (`next_timer_deadline` may still be
1167        // Some for other reasons, but not from a dwell; here nothing else is
1168        // active, so it must be None once the tooltip is shown and settled.)
1169        let mut tree = tree_with_backend();
1170        tree.set_accessibility_preferences(false, true, 1.0); // reduced motion → no fade deadline
1171        let btn = tree.add(Button::new(lit!("Hover")).tooltip(lit!("Plain")));
1172        tree.layout(SizeProposal::exact(400.0, 200.0));
1173        tree.pointer_move(tree.bounds(btn).center());
1174        tree.advance_time(Duration::from_millis(550));
1175        assert_eq!(tree.active_overlays().len(), 1, "plain tooltip shown");
1176        tree.layout(SizeProposal::exact(400.0, 200.0));
1177
1178        assert!(
1179            tree.next_timer_deadline().is_none(),
1180            "a plain tooltip must not schedule a dwell wake deadline"
1181        );
1182    }
1183}
1184
1185/// **Drift guard: every tooltip body in the workspace is deferred.**
1186///
1187/// Not a style rule. An eagerly-added tooltip is invisible until it lands on a
1188/// widget that a data view rebuilds per row, and then it is a freeze: 29 rows of
1189/// Skribisto's Overview carried 1,305 tooltip widgets in a 22,737-node subtree,
1190/// and one arrow-key press spent 5.3 s **destroying** it. The cost is in the
1191/// teardown, so it does not show up in a build profile and it is not the kind of
1192/// thing review catches.
1193///
1194/// The fix was a sweep of ~45 call sites across 35 files, which is exactly the
1195/// kind of thing that grows back one widget at a time. So the rule is checked:
1196/// a tooltip body reaches the arena through the doors in this module, or the
1197/// build is red.
1198#[cfg(test)]
1199mod deferred_tooltip_drift {
1200    use std::path::{Path, PathBuf};
1201
1202    /// Every `.rs` under the workspace's `crates/`, production halves only.
1203    ///
1204    /// Test code is cut at the first `#[cfg(test)]` — this workspace puts test
1205    /// modules at the bottom of the file, and a test that deliberately drives
1206    /// the low-level `attach_tooltip` path is exercising the framework, not
1207    /// shipping a tooltip.
1208    fn production_sources() -> Vec<(PathBuf, String)> {
1209        fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
1210            let Ok(entries) = std::fs::read_dir(dir) else {
1211                return;
1212            };
1213            for entry in entries.flatten() {
1214                let path = entry.path();
1215                if path.is_dir() {
1216                    walk(&path, out);
1217                } else if path.extension().is_some_and(|e| e == "rs") {
1218                    out.push(path);
1219                }
1220            }
1221        }
1222        let crates_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
1223            .parent()
1224            .expect("teksilo-widgets sits in crates/")
1225            .to_path_buf();
1226        let mut files = Vec::new();
1227        walk(&crates_dir, &mut files);
1228        files
1229            .into_iter()
1230            .filter_map(|path| {
1231                let text = std::fs::read_to_string(&path).ok()?;
1232                let production = match text.find("#[cfg(test)]") {
1233                    Some(cut) => text[..cut].to_string(),
1234                    None => text,
1235                };
1236                Some((path, production))
1237            })
1238            .collect()
1239    }
1240
1241    #[test]
1242    fn no_tooltip_body_is_added_eagerly() {
1243        // `ctx.add(..)` / `ctx.add_boxed(..)` / `ctx.add_detached(..)` handing over
1244        // a tooltip body. The deferred doors (`add_deferred_on_demand`,
1245        // `add_detached_deferred_on_demand`) do not match, which is the point.
1246        const EAGER: [&str; 3] = ["ctx.add(", "ctx.add_boxed(", "ctx.add_detached("];
1247        const BODIES: [&str; 3] = [
1248            "TooltipWidget::new",
1249            "RichTooltipWidget::",
1250            "CompositeTooltipWidget::new",
1251        ];
1252
1253        let mut offenders: Vec<String> = Vec::new();
1254        for (path, text) in production_sources() {
1255            for (n, line) in text.lines().enumerate() {
1256                // Prose about the rule is not a breach of it — this module's own
1257                // doc comment spells the banned shape out.
1258                if line.trim_start().starts_with("//") {
1259                    continue;
1260                }
1261                // The add and the body land on one line at every site the sweep
1262                // found; a split one still shows up because the `let x = ctx.add(`
1263                // half carries the variable the next line builds into, and the
1264                // doors are the only other way to reach the arena.
1265                if EAGER.iter().any(|a| line.contains(a)) && BODIES.iter().any(|b| line.contains(b))
1266                {
1267                    offenders.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
1268                }
1269            }
1270        }
1271
1272        assert!(
1273            offenders.is_empty(),
1274            "a tooltip body is added eagerly — route it through \
1275             `attach_plain_tooltip`, `attach_rich_tooltip*` or \
1276             `attach_composite_tooltip*`, which defer it until a dwell matures:\n{}",
1277            offenders.join("\n")
1278        );
1279    }
1280}