Files
ltk/src/widget/tooltip/mod.rs
T
admin 02ab10b3ef
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
State restored on an ordinary launch for apps that ask, focus that follows its widget's id, buttons that do not take the focus when pressed, a focus ring only after the keyboard, and a browser app's surfaces copied rather than transferred, which WebKit got wrong
Restore on launch. `restore_state` follows Android's saved-instance-state contract: the saved bytes come back after a crash or a session restore, and a launch from the launcher starts fresh. Some apps keep state their users expect to find however the last run ended, as an Android browser keeps its tabs in its own storage even when closed from the app switcher. `App::restore_on_launch`, false by default, has the runtime restore on a plain launch too; `SessionRuntime::bootstrap` asks it before deciding. Saving, the file layout and the window geometry are the same either way. Its documentation explains when it is asked, that it is not when persistence is off, that `restore_state` must then merge what the launch itself asks for, such as a URL on the command line, and that what `save_state` returns now outlives every exit; it carries a compiled example. `restore_state`'s documentation, `docs/onboarding.md` and the cookbook's session state recipe mention it.
Focus on a press. `Button::focusable( false )` and `WindowButton` kept out of the Tab cycle but still took the focus when pressed, and Enter then pressed them again, which closed a browser's tab when the user pressed Enter in a page after closing another tab. `LaidOutWidget` gains `focus_on_press`, from `Element::focus_on_press`: a button's `focusable`, a `WindowButton`'s, a pressable's new `Pressable::focus_on_press`, true by default, and true for every other widget. `focus_pressed` replaces `set_focus` for pointer and touch presses and leaves the focus where it was when the widget does not take it. Enter's fallback to the widget under the pointer, which `hovered_idx` follows, skips such widgets too, and a rebuild that leaves the focus on one drops it (`drop_misplaced_focus`). The close buttons of `tab_strip`, on the desktop and on the phone's cards, are not focusable; `symbol_button` takes whether its button is.
Focus by id. The focus was held as the index of a widget in the laid-out tree, so a rebuild that inserted or removed widgets before it moved it to whatever widget took that index. `WidgetId` becomes a name and a key, `WidgetId::keyed( name, key )` telling apart widgets of the same kind, such as the tabs of a strip; `WidgetId( "name" )` still builds one, through a `const fn` of that name, so the calls in every app compile unchanged, and only code that read `.0`, the toasts, the combo and the tooltip here, moves to the derived `Hash` or `name()`. After each layout `follow_focused_id` finds the focused id and moves the focus to its new index with the field's pending text, cursor, selection and scroll; it drops the focus when the widget is gone, keeps it when a scroll only clipped it, and leaves it when the widget at the old index still has the id. `draw_surface` returns whether the focus moved, and its callers draw the surface again rather than marking it done, since the frame just drawn showed the focus in the old place. Widgets without an id keep the index. Each tab and each card of `tab_strip` carries `WidgetId::keyed` with the tab's key, so a chosen tab keeps the focus while others close.
Focus ring. A press that focused a button, a toggle or a list item left its ring drawn, which browsers have not done since `:focus-visible`. `SurfaceState::focus_visible` is set by Tab and Shift+Tab and cleared by a press that moves the focus, `show_focus_ring` redrawing the whole surface when it changes, since the partial redraw only sees indices. The draw is given the focused index only while the ring shows or the widget is a text field, which draws its cursor however it was focused; the focus itself is unchanged, so Enter and Space still press the widget.
Key repeat. A key repeated by the platform rather than by ltk's own timer reached the focused widget even while `App::claims_keyboard` was true; `key_repeated` checks it as `key_press` does.
Browser surfaces. Every surface is painted through the app's one WebGL context on one `OffscreenCanvas`, and was handed to its own canvas, a `bitmaprenderer`, with `transferToImageBitmap`. WebKit reuses the buffer of a bitmap it was handed, so in a WebKit browser each surface showed another's frame, or none, and only the last one painted was right: forge/web's background, dock and windows came and went in a WPE browser while Chromium showed them all. A test page with two canvases fed from one offscreen canvas showed their colours swapped in WPE and right with a copy. Each surface's canvas is now a `2d` one, and `present_copy` copies the offscreen canvas with `drawImage` in `copy` composition, set before every copy since resizing a canvas resets its context, so a transparent frame replaces the last rather than leaving trails behind an animation. Subsurfaces go the same way. `WebSurfaceState` loses `bitmap` and `new` its `gpu` argument, and web-sys loses the `ImageBitmap` and `ImageBitmapRenderingContext` features.
CHANGELOG records each of these.
Not done. The tests that build `LaidOutWidget` by hand set the new field, but no test covers a press on a non-focusable button, the focus following an id or the ring. For one frame after a rebuild moves the focused widget, its ring and its cursor may show in the old place.
2026-11-01 09:19:23 +01:00

176 lines
5.3 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Tooltip — short hint anchored below a widget.
//!
//! Like [`Toast`](super::toast::Toast), `Tooltip` is **not** an
//! [`Element`]; it is a builder that produces an [`OverlaySpec`] for
//! the application to return from [`App::overlays`](crate::App::overlays).
//! The overlay is rendered as an `xdg-popup` anchored to the widget
//! tagged with [`Tooltip::anchor_id`] in the previous frame's layout,
//! so the hint appears flush below the trigger and follows it across
//! resizes and re-layouts.
//!
//! Hover detection and the show / hide delay are the **application's
//! responsibility**. ltk does not currently expose pointer-hover
//! callbacks at the widget level, so the typical pattern is:
//!
//! 1. Track which widget the pointer is over via `App::on_tap` or
//! a custom hover bookkeeping you maintain in `update`.
//! 2. Start a timer (via [`App::set_channel_sender`](crate::App::set_channel_sender))
//! that fires a "show tooltip" message after the desired delay.
//! 3. Return the [`Tooltip::overlay`] from `overlays()` only while the
//! tooltip should be visible.
//!
//! ```rust,no_run
//! # use ltk::{ tooltip, OverlaySpec, WidgetId };
//! # struct App { tooltip_for: Option<WidgetId> }
//! # impl App {
//! fn overlays( &self ) -> Vec<OverlaySpec<()>>
//! {
//! match self.tooltip_for
//! {
//! Some( id ) => vec![ tooltip( "Save the document", id ).overlay() ],
//! None => vec![],
//! }
//! }
//! # }
//! ```
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use crate::app::{ Anchor, Layer, OverlayId, OverlaySpec };
use crate::types::{ Color, Length, WidgetId };
use super::Element;
mod theme;
#[ cfg( test ) ]
mod tests;
/// Builder for an `xdg-popup`-backed hint anchored to a widget.
pub struct Tooltip<Msg: Clone>
{
/// Body text rendered inside the tooltip.
pub( crate ) message: String,
/// Stable identifier of the widget the popup anchors to. The widget
/// must have been built with `.id( anchor_id )` so the runtime can
/// look its rect up in the previous frame's layout snapshot.
pub( crate ) anchor_id: WidgetId,
/// Maximum width (logical pixels). The tooltip is single-line; long
/// strings are clipped at the surface boundary.
pub( crate ) max_width: u32,
/// Optional override for the tooltip background colour.
pub( crate ) bg: Option<Color>,
/// Optional override for the body text colour.
pub( crate ) fg: Option<Color>,
/// Optional message fired when the user taps outside the popup.
pub( crate ) on_dismiss: Option<Msg>,
}
impl<Msg: Clone + 'static> Tooltip<Msg>
{
/// Create a tooltip with the given message anchored to `anchor_id`.
pub fn new( message: impl Into<String>, anchor_id: WidgetId ) -> Self
{
Self
{
message: message.into(),
anchor_id,
max_width: theme::MAX_W,
bg: None,
fg: None,
on_dismiss: None,
}
}
/// Override the maximum tooltip width. Defaults to 320 logical px.
pub fn max_width( mut self, w: u32 ) -> Self
{
self.max_width = w;
self
}
/// Override the background colour.
pub fn background( mut self, c: Color ) -> Self
{
self.bg = Some( c );
self
}
/// Override the body text colour.
pub fn color( mut self, c: Color ) -> Self
{
self.fg = Some( c );
self
}
/// Set the dismiss message fired on a tap outside the popup.
pub fn on_dismiss( mut self, msg: Msg ) -> Self
{
self.on_dismiss = Some( msg );
self
}
/// Build the [`OverlaySpec`] the application returns from
/// [`App::overlays`](crate::App::overlays).
pub fn overlay( self ) -> OverlaySpec<Msg>
{
use super::{ container, text };
let mut hasher = DefaultHasher::new();
"tooltip".hash( &mut hasher );
self.anchor_id.hash( &mut hasher );
let overlay_id = OverlayId( hasher.finish() as u32 );
let bg = self.bg.unwrap_or_else( theme::bg );
let fg = self.fg.unwrap_or_else( theme::text );
let body: Element<Msg> = container(
text( self.message.clone() )
.size( Length::widget( theme::FONT_SIZE ) )
.color( fg )
)
.background( bg )
.padding_h( Length::widget( theme::PAD_H ) )
.padding_v( Length::widget( theme::PAD_V ) )
.radius( theme::RADIUS )
.into();
OverlaySpec
{
id: overlay_id,
layer: Layer::Overlay,
anchor: Anchor::TOP,
// Height scales with the display so the fluid text always fits; the
// caller-set max width stays fixed so wrapping is predictable.
size: ( Length::px( self.max_width as f32 ), Length::widget( theme::HEIGHT as f32 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: body,
on_dismiss: self.on_dismiss,
anchor_widget_id: Some( self.anchor_id ),
}
}
}
/// Create a [`Tooltip`] anchored to the widget tagged with `anchor_id`.
///
/// ```rust,no_run
/// # use ltk::{ tooltip, OverlaySpec, WidgetId };
/// # fn _ex() -> OverlaySpec<()> {
/// tooltip( "Click to save", WidgetId( "btn/save" ) )
/// .max_width( 240 )
/// .overlay()
/// # }
/// ```
pub fn tooltip<Msg: Clone + 'static>(
message: impl Into<String>,
anchor_id: WidgetId,
) -> Tooltip<Msg>
{
Tooltip::new( message, anchor_id )
}