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