Expand description
Material 3 Expressive tooltips: a plain, auto-dismissing label and a rich panel with a title, supporting text and up to two action buttons — both opened by hovering (pointer platforms) or a long-press (touch), and floated above the app by the framework’s overlay portal.
§Riding the framework portal
Each panel’s own view is an owner: it holds one
frust::authoring::OverlaySlot, lays its pod out against the window, and
— on every paint where TooltipHover::is_open reads true — registers it
with the root, which paints it after the whole main tree and hit-tests it
before the main tree, per the band and input class the owner declared. An
app mounts the owner unconditionally, exactly as before; it costs one
layout of the panel per frame and, while closed, nothing else.
frust::overlay_portal’s declarative wrapper anchors to its own child’s
bounds, which does not fit here: a tooltip’s trigger and panel are two
independently-mounted sibling views, so the panel’s slot anchors instead to
the rect the trigger captured into the shared handle below
(OverlayAnchor::Rect).
Two consequences of floating a pod rather than hosting it in the main tree, both of them the portal’s own documented v1 shape rather than this port’s choice: a pod publishes no accessibility nodes (they would attach under the owner’s node, at the owner’s position rather than the floated rect’s), and a pod receives no broadcast events — neither panel needs one, since the trigger’s long-press timer lives in the main tree and neither panel’s own content runs a threshold-time gesture.
§The shared handle
TooltipHover is the one non-reactive latch a caller keeps per tooltip —
the trigger writes it, the panel reads it, and
crate::overlay::anchored’s own OverlayAnchor cell rides inside it
(carrying the trigger’s window-space rect) so a caller never juggles two
handles. It is not reactive (docs/CODE_STANDARDS.md’s convention for
this catalog’s other paint-latched handles, e.g.
crate::overlay::anchored::OverlayAnchor itself): writing it wakes
nothing on its own, so every writer also calls
PaintCtx::request_frame/relies on the pointer event that carried it — see
each write site below. It is read live, during the layout/paint of the very
frame that wrote it, which is what lets the panel appear and disappear
without waiting for a rebuild.
§Hover vs. long-press
tooltip_trigger wraps an arbitrary child transparently (every event
still reaches it) and does two independent things:
- Hover is read the same way
crate::overlay’s shadcn-ported sibling catalog reads it for its own tooltip (frust_shadcn::components::tooltip):PaintCtx::is_hoveredis the authoritative signal (self-correcting every paint, the one pass that reliably learns a pointer left without a dedicated event), claimed for viaEventCtx::claim_hoveron an in-boundsMoveso a hovered control inside the child still wins the claim and this wrapper still reads hovered through the path (the claim-ordering rule). A transition writesTooltipHoverand requests a frame; the panel’s owner reads that latch live in its ownpaint, so the frame the write asked for is the frame the panel appears on — or vanishes on. - Long-press is delegated whole to
frust::GestureDetector(nested as this trigger’s one child, wrapping the caller’s real child in turn) rather than hand-rolled: touch has no hover, andGestureDetector’s own paint-clock timer +frust_core::mark_pending_result_flushmachinery is the only way a plugin crate reaches a threshold-time fire with no further pointer event (crate::toggle_button’s own module docs explain why that function is unreachable from a design-system plugin directly — see its “Long-press” section). The callback ignores the&mut Stateit is handed and writes straight into the shared, non-reactiveTooltipHoverinstead.
TooltipTriggerView::enabled gates both: false suppresses hover opening
it, the long-press callback becomes a no-op, and an already-open tooltip
snaps shut the moment enabled goes false mid-show.
§Plain: auto-dismiss, long-press only
M3ETooltipState._scheduleHide (m3e_tooltips.dart:72-78) arms a
MaterialMotion::EXTRA_LONG_4-long (1000ms) one-shot timer only from
the onLongPress handler — hover’s onEnter never calls it, closing purely
on onExit instead — and never for a rich tooltip (if (widget._isRich) return;). This port’s plain panel widget captures
hover.via() == TooltipOpenVia::LongPress once, on the first paint of each
show (the same instant upstream’s _show(); _scheduleHide(); pair runs
synchronously together), and runs the matching paint-clock timer only when
that was true — a hover-shown plain tooltip never starts one, matching
upstream exactly. “Each show” is counted by TooltipHover itself, which
ticks a counter every time the latch goes from closed to open; a pod that
stays mounted across many shows re-arms from that edge rather than from its
own View::build, which now happens only once.
§Rich: title/supporting/actions, dismiss on tap-elsewhere
A rich panel never runs the auto-dismiss timer above; it stays open while
the trigger keeps its hover, closes on hover-exit exactly like the plain
panel, and additionally dismisses on a tap anywhere outside itself —
upstream’s own Positioned.fill(child: GestureDetector(onTap: _hide, behavior: HitTestBehavior.translucent)), ported as the portal’s own
frust::OutsideTap::Notify { consume: false } rather than a
hand-rolled translucent layer: the root tells the owner that a press landed
outside every floated surface, the owner closes the latch, and the press
carries on into the main tree — translucent, exactly as upstream. Its
actions row is routed to normally, as
frust::OverlayInput::Interactive content.
Structs§
- Rich
Tooltip View - A declarative rich tooltip. See
rich_tooltip. - Rich
Tooltip Widget - The retained widget for a
RichTooltipView: oneOverlaySlotholding the rich panel, registered on every paint the latch reads open and on no other. SeeTooltipWidgetfor what that gates, and this view’s ownconfigurefor how the two panels’ registrations differ. - Tooltip
Action - One rich-tooltip action button: a label plus its own press callback. Build
with
tooltip_action, attach withRichTooltipView::action. - Tooltip
Hover - The shared, non-reactive handle a caller keeps one of per tooltip: whether
the panel should be showing, how it was opened, whether the trigger is
enabled, and the trigger’s captured window-space rect (the merged
crate::overlay::anchoredhost’s ownOverlayAnchor, carried inside rather than threaded separately). See the module docs. - Tooltip
Trigger View - A declarative tooltip trigger. See
tooltip_trigger. - Tooltip
Trigger Widget - The retained widget for a
TooltipTriggerView. - Tooltip
View - A declarative plain tooltip. See
tooltip. - Tooltip
Widget - The retained widget for a
TooltipView: oneOverlaySlotholding the plain panel, registered on every paint the latch reads open and on no other.
Functions§
- rich_
tooltip - Build a rich tooltip over
hover, to be mounted unconditionally besidetooltip_trigger’s own view. Seetooltipfor the mount contract this mirrors, andRichTooltipView::title/RichTooltipView::actionfor the upstreamrichTitle/actionsslots. - tooltip
- Build a plain tooltip over
hover, to be mounted unconditionally besidetooltip_trigger’s own view — it floats its own panel above the app and registers nothing at all while closed (see the module docs’ portal section). - tooltip_
action - Build a rich-tooltip action labeled
label, firingon_press. - tooltip_
trigger - Wrap
childas a tooltip trigger writing intohover. See the module docs’ Hover vs. long-press section.