event_loop, widget, input: pointer-dwell tooltips, global drag coords, foreign-toplevel name cascade
`Button::tooltip( text )` registers a hint string that fires after a 600 ms pointer dwell. `LaidOutWidget` gains a `tooltip: Option<String>` field, `Element::tooltip()` exposes it to the input layer, and the existing pointer-hover path now calls `arm_tooltip` on hover-enter and `cancel_tooltip` on hover-leave / touch. The deadline is polled alongside `next_long_press_wakeup` in `try_run` so an idle pointer still gets a wake-up at the firing instant; on fire, `tooltip_overlay()` synthesises an `OverlaySpec` — a rounded `text_primary @ 95%` pill drawn with `bg` text — anchored above the hovered widget, flipping below or clamping inside the screen if it would clip, and pushed alongside the app's own overlays both in the redraw path and in `reconcile_overlays` so the layer surface is created the same frame the tooltip becomes visible. Pointer-only by design: touch events explicitly cancel because a tap-and-release should never linger into a hint. The `showcase` example wires `.tooltip(..)` on the three button variants as a smoke test. The drag pipeline now reports positions in main-surface (global) coordinates instead of per-surface. `surface_offset_for( focus )` derives the top-left of an overlay surface from `SurfaceState::layer_anchor` — newly stored at `reconcile_overlays` time from `OverlaySpec::anchor` — combined with the main surface's dimensions; `on_drag_move`, `on_drop`, the synthetic move emitted on drag-promotion in `pointer.rs`, and the `pending_drag_inits` push site in `gesture::start_drag` all translate before handing coordinates to the app. The motion and release paths additionally `request_redraw()` every overlay so a dock-style drop target painted on an `Anchor::Bottom` layer surface gets repainted as the drag moves — without that, the visible drop indicator only updates when the cursor re-enters the main surface. Drops still target whichever surface fired the release; only the coordinates are unified. `ForeignToplevelListHandler` previously read `app_id` directly via `ForeignToplevelList::info()`. Clients that never set `app_id` (some winit-windowed compositors, simple test clients) were silently invisible to crustace-style docks because the empty string fell through `unwrap_or_default()` and the dock then keyed entries off `""`. `toplevel_display_id()` cascades: prefer `app_id` for desktop-entry matching, fall back to `title` for human-readable identification, and finally to the protocol-issued `identifier` which is always present and unique per handle. Applied to both `new_toplevel` and `update_toplevel`. `theme::system_fontdb()` lazily loads the system font database once via `OnceLock` and reuses the `Arc` for every `decode_svg_bytes` call. resvg's default `Options::fontdb` is empty, so any SVG containing `<text>` rendered with the built-in fallback font or no font at all; with the system DB attached, icons and decorative SVGs with embedded labels now resolve glyphs correctly. Cached because `load_system_fonts()` walks every font path on the system and is comfortably tens of milliseconds on a cold cache — not something to repeat per icon decode. `themes/default/theme.json` tweaks one variant's slot palette: `surface-alt` from `@indigo/D9` to `@white/D9` and `text-primary` from `@white` to `@navy`, plus a cosmetic re-alignment of the `"value"` columns in the same slots block.
This commit is contained in:
@@ -97,16 +97,19 @@ impl App for ShowcaseApp
|
|||||||
button( "Primary" )
|
button( "Primary" )
|
||||||
.variant( ButtonVariant::Primary )
|
.variant( ButtonVariant::Primary )
|
||||||
.id( Self::SAVE_BUTTON_ID )
|
.id( Self::SAVE_BUTTON_ID )
|
||||||
|
.tooltip( "Hover-dwell tooltip via .tooltip()" )
|
||||||
.on_press( Message::Pressed( "Primary" ) ),
|
.on_press( Message::Pressed( "Primary" ) ),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
button( "Secondary" )
|
button( "Secondary" )
|
||||||
.variant( ButtonVariant::Secondary )
|
.variant( ButtonVariant::Secondary )
|
||||||
|
.tooltip( "Secondary action" )
|
||||||
.on_press( Message::Pressed( "Secondary" ) ),
|
.on_press( Message::Pressed( "Secondary" ) ),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
button( "Tertiary" )
|
button( "Tertiary" )
|
||||||
.variant( ButtonVariant::Tertiary )
|
.variant( ButtonVariant::Tertiary )
|
||||||
|
.tooltip( "Tertiary action" )
|
||||||
.on_press( Message::Pressed( "Tertiary" ) ),
|
.on_press( Message::Pressed( "Tertiary" ) ),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ mod tests
|
|||||||
handlers: WidgetHandlers::None,
|
handlers: WidgetHandlers::None,
|
||||||
keyboard_focusable: true,
|
keyboard_focusable: true,
|
||||||
cursor: crate::types::CursorShape::Default,
|
cursor: crate::types::CursorShape::Default,
|
||||||
|
tooltip: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
},
|
},
|
||||||
keyboard_focusable: false,
|
keyboard_focusable: false,
|
||||||
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
|
cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ),
|
||||||
|
tooltip: None,
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
|
layout_and_draw::<Msg>( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 )
|
||||||
@@ -378,6 +379,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
handlers: other.handlers(),
|
handlers: other.handlers(),
|
||||||
keyboard_focusable: other.is_focusable(),
|
keyboard_focusable: other.is_focusable(),
|
||||||
cursor: other.cursor_shape(),
|
cursor: other.cursor_shape(),
|
||||||
|
tooltip: other.tooltip().map( str::to_string ),
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
flat_idx + 1
|
flat_idx + 1
|
||||||
|
|||||||
@@ -239,6 +239,26 @@ pub( crate ) enum SurfaceFocus
|
|||||||
Overlay( OverlayId ),
|
Overlay( OverlayId ),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
|
||||||
|
|
||||||
|
#[derive( Clone )]
|
||||||
|
pub struct TooltipPending
|
||||||
|
{
|
||||||
|
pub focus: SurfaceFocus,
|
||||||
|
pub flat_idx: usize,
|
||||||
|
pub deadline: std::time::Instant,
|
||||||
|
pub text: String,
|
||||||
|
pub anchor: crate::types::Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive( Clone )]
|
||||||
|
pub struct TooltipVisible
|
||||||
|
{
|
||||||
|
pub focus: SurfaceFocus,
|
||||||
|
pub anchor: crate::types::Rect,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for a layer-shell surface, used both for the main surface
|
/// Configuration for a layer-shell surface, used both for the main surface
|
||||||
/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay
|
/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay
|
||||||
/// returned by [`crate::app::App::overlays`].
|
/// returned by [`crate::app::App::overlays`].
|
||||||
@@ -415,6 +435,7 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
|||||||
/// for slide-down / grow animations). `(0, 0)` for non-layer surfaces
|
/// for slide-down / grow animations). `(0, 0)` for non-layer surfaces
|
||||||
/// and for layer surfaces before their first configure.
|
/// and for layer surfaces before their first configure.
|
||||||
pub last_requested_size: ( u32, u32 ),
|
pub last_requested_size: ( u32, u32 ),
|
||||||
|
pub layer_anchor: Option<crate::app::Anchor>,
|
||||||
/// Anchor rect the xdg-popup positioner was last configured with.
|
/// Anchor rect the xdg-popup positioner was last configured with.
|
||||||
/// `None` for non-popup surfaces.
|
/// `None` for non-popup surfaces.
|
||||||
pub last_popup_anchor: Option<Rect>,
|
pub last_popup_anchor: Option<Rect>,
|
||||||
@@ -465,6 +486,7 @@ impl<Msg: Clone> SurfaceState<Msg>
|
|||||||
titlebar_close_rect: Rect::default(),
|
titlebar_close_rect: Rect::default(),
|
||||||
scale_factor: 1,
|
scale_factor: 1,
|
||||||
last_requested_size: ( 0, 0 ),
|
last_requested_size: ( 0, 0 ),
|
||||||
|
layer_anchor: None,
|
||||||
last_popup_anchor: None,
|
last_popup_anchor: None,
|
||||||
popup_reposition_token: 0,
|
popup_reposition_token: 0,
|
||||||
frame_pending: false,
|
frame_pending: false,
|
||||||
@@ -674,6 +696,9 @@ pub struct AppData<A: App>
|
|||||||
/// before the user's finger / cursor moves — useful on mouse where the
|
/// before the user's finger / cursor moves — useful on mouse where the
|
||||||
/// pointer might sit perfectly still between press and drag.
|
/// pointer might sit perfectly still between press and drag.
|
||||||
pub pending_drag_inits: Vec<Point>,
|
pub pending_drag_inits: Vec<Point>,
|
||||||
|
|
||||||
|
pub tooltip_pending: Option<TooltipPending>,
|
||||||
|
pub tooltip_visible: Option<TooltipVisible>,
|
||||||
pub qh: QueueHandle<Self>,
|
pub qh: QueueHandle<Self>,
|
||||||
/// Last pointer serial (needed for interactive move).
|
/// Last pointer serial (needed for interactive move).
|
||||||
pub last_pointer_serial: u32,
|
pub last_pointer_serial: u32,
|
||||||
@@ -1894,6 +1919,120 @@ impl<A: App> AppData<A>
|
|||||||
soonest
|
soonest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
|
||||||
|
{
|
||||||
|
let ss = self.surface( focus );
|
||||||
|
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
|
||||||
|
let Some( text ) = w.tooltip.clone() else
|
||||||
|
{
|
||||||
|
self.tooltip_pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let anchor = w.rect;
|
||||||
|
if let Some( ref p ) = self.tooltip_pending
|
||||||
|
{
|
||||||
|
if p.focus == focus && p.flat_idx == flat_idx { return; }
|
||||||
|
}
|
||||||
|
self.tooltip_pending = Some( TooltipPending
|
||||||
|
{
|
||||||
|
focus,
|
||||||
|
flat_idx,
|
||||||
|
deadline: std::time::Instant::now() + TOOLTIP_DELAY,
|
||||||
|
text,
|
||||||
|
anchor,
|
||||||
|
} );
|
||||||
|
if self.tooltip_visible.is_some()
|
||||||
|
{
|
||||||
|
self.tooltip_visible = None;
|
||||||
|
self.overlays_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn cancel_tooltip( &mut self )
|
||||||
|
{
|
||||||
|
let was_visible = self.tooltip_visible.take().is_some();
|
||||||
|
self.tooltip_pending = None;
|
||||||
|
if was_visible { self.overlays_dirty = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn next_tooltip_wakeup( &self ) -> Option<std::time::Duration>
|
||||||
|
{
|
||||||
|
let p = self.tooltip_pending.as_ref()?;
|
||||||
|
Some( p.deadline.saturating_duration_since( std::time::Instant::now() ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn check_tooltip_deadline( &mut self )
|
||||||
|
{
|
||||||
|
let Some( p ) = self.tooltip_pending.as_ref() else { return };
|
||||||
|
if std::time::Instant::now() < p.deadline { return; }
|
||||||
|
let p = self.tooltip_pending.take().unwrap();
|
||||||
|
self.tooltip_visible = Some( TooltipVisible
|
||||||
|
{
|
||||||
|
focus: p.focus,
|
||||||
|
anchor: p.anchor,
|
||||||
|
text: p.text,
|
||||||
|
} );
|
||||||
|
self.overlays_dirty = true;
|
||||||
|
self.main.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn tooltip_overlay( &self ) -> Option<crate::app::OverlaySpec<A::Message>>
|
||||||
|
{
|
||||||
|
let v = self.tooltip_visible.as_ref()?;
|
||||||
|
let ( ox, oy ) = self.surface_offset_for( v.focus );
|
||||||
|
let scale = self.surface( v.focus ).scale_factor as f32;
|
||||||
|
let anchor_x = ox + v.anchor.x / scale;
|
||||||
|
let anchor_y = oy + v.anchor.y / scale;
|
||||||
|
let anchor_w = v.anchor.width / scale;
|
||||||
|
let anchor_h = v.anchor.height / scale;
|
||||||
|
|
||||||
|
let palette = crate::theme::palette();
|
||||||
|
let bg_col = crate::types::Color::rgba( palette.text_primary.r, palette.text_primary.g, palette.text_primary.b, 0.95 );
|
||||||
|
let pill: crate::widget::Element<A::Message> = crate::widget::container(
|
||||||
|
crate::widget::text( v.text.clone() )
|
||||||
|
.size( 13.0 )
|
||||||
|
.color( palette.bg )
|
||||||
|
)
|
||||||
|
.background( bg_col )
|
||||||
|
.padding_h( 12.0 )
|
||||||
|
.padding_v( 6.0 )
|
||||||
|
.radius( 8.0 )
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let estimated_w = ( v.text.chars().count() as f32 * 7.5 + 24.0 ).clamp( 40.0, 320.0 );
|
||||||
|
let estimated_h = 28.0_f32;
|
||||||
|
let mut x = anchor_x + ( anchor_w - estimated_w ) / 2.0;
|
||||||
|
let mut y = anchor_y - estimated_h - 6.0;
|
||||||
|
let sw = self.main.width as f32;
|
||||||
|
let sh = self.main.height as f32;
|
||||||
|
x = x.clamp( 4.0, ( sw - estimated_w - 4.0 ).max( 4.0 ) );
|
||||||
|
if y < 4.0 { y = anchor_y + anchor_h + 6.0; }
|
||||||
|
if y + estimated_h > sh - 4.0 { y = ( sh - estimated_h - 4.0 ).max( 4.0 ); }
|
||||||
|
|
||||||
|
let view: crate::widget::Element<A::Message> = crate::layout::stack::stack()
|
||||||
|
.push_translated( pill, crate::layout::stack::HAlign::Start, crate::layout::stack::VAlign::Top, x, y )
|
||||||
|
.into();
|
||||||
|
|
||||||
|
use std::hash::{ Hash, Hasher };
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
"ltk-tooltip".hash( &mut hasher );
|
||||||
|
let id = crate::app::OverlayId( hasher.finish() as u32 );
|
||||||
|
|
||||||
|
Some( crate::app::OverlaySpec
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
layer: crate::app::Layer::Overlay,
|
||||||
|
anchor: crate::app::Anchor::ALL,
|
||||||
|
size: ( 0, 0 ),
|
||||||
|
exclusive_zone: -1,
|
||||||
|
keyboard_exclusive: false,
|
||||||
|
input_region: Some( Vec::new() ),
|
||||||
|
view,
|
||||||
|
on_dismiss: None,
|
||||||
|
anchor_widget_id: None,
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
|
||||||
/// Fire long-press messages for any surface whose deadline has
|
/// Fire long-press messages for any surface whose deadline has
|
||||||
/// elapsed. Idempotent — if a press is in-flight but not yet due this
|
/// elapsed. Idempotent — if a press is in-flight but not yet due this
|
||||||
/// does nothing. On fire, the message is pushed to `pending_msgs`,
|
/// does nothing. On fire, the message is pushed to `pending_msgs`,
|
||||||
@@ -1960,7 +2099,9 @@ impl<A: App> AppData<A>
|
|||||||
if let Some( m ) = ds_msg
|
if let Some( m ) = ds_msg
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( m );
|
self.pending_msgs.push( m );
|
||||||
self.pending_drag_inits.push( origin );
|
let ( ox, oy ) = self.surface_offset_for( focus );
|
||||||
|
let global = crate::Point { x: origin.x + ox, y: origin.y + oy };
|
||||||
|
self.pending_drag_inits.push( global );
|
||||||
// Drag promotion cancels any held-button repeat — the
|
// Drag promotion cancels any held-button repeat — the
|
||||||
// gesture has switched semantics and the timer has
|
// gesture has switched semantics and the timer has
|
||||||
// nothing to fire against any more.
|
// nothing to fire against any more.
|
||||||
|
|||||||
@@ -412,6 +412,23 @@ impl<A: App> ProvidesRegistryState for AppData<A>
|
|||||||
// handle proxy — unique per session and stable for the handle's
|
// handle proxy — unique per session and stable for the handle's
|
||||||
// lifetime, the same value paired across `Opened` and the matching
|
// lifetime, the same value paired across `Opened` and the matching
|
||||||
// `Closed`.
|
// `Closed`.
|
||||||
|
/// Cascade for the per-toplevel display string crustace-style shells use to
|
||||||
|
/// register a window: prefer `app_id` (sets `.desktop` matching), fall back
|
||||||
|
/// to `title` (visible to the user), and finally to the protocol-issued
|
||||||
|
/// `identifier` (always present, unique per handle). Without the cascade,
|
||||||
|
/// any client that never set `app_id` (e.g. winit-windowed compositors
|
||||||
|
/// before they bind their xdg surface) was silently invisible to the dock.
|
||||||
|
fn toplevel_display_id(
|
||||||
|
list: &ForeignToplevelList,
|
||||||
|
handle: &ExtForeignToplevelHandleV1,
|
||||||
|
) -> String
|
||||||
|
{
|
||||||
|
let Some( info ) = list.info( handle ) else { return String::new(); };
|
||||||
|
if !info.app_id.is_empty() { return info.app_id; }
|
||||||
|
if !info.title.is_empty() { return info.title; }
|
||||||
|
info.identifier
|
||||||
|
}
|
||||||
|
|
||||||
impl<A: App> ForeignToplevelListHandler for AppData<A>
|
impl<A: App> ForeignToplevelListHandler for AppData<A>
|
||||||
{
|
{
|
||||||
fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList
|
fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList
|
||||||
@@ -427,9 +444,7 @@ impl<A: App> ForeignToplevelListHandler for AppData<A>
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
let id = handle.id().protocol_id();
|
let id = handle.id().protocol_id();
|
||||||
let app_id = self.foreign_toplevel_list.info( &handle )
|
let app_id = toplevel_display_id( &self.foreign_toplevel_list, &handle );
|
||||||
.map( |i| i.app_id )
|
|
||||||
.unwrap_or_default();
|
|
||||||
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } )
|
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
@@ -451,9 +466,7 @@ impl<A: App> ForeignToplevelListHandler for AppData<A>
|
|||||||
// but the app_id-can-change-mid-life case still produces a
|
// but the app_id-can-change-mid-life case still produces a
|
||||||
// correct sequence (old refcount drops, new one bumps).
|
// correct sequence (old refcount drops, new one bumps).
|
||||||
let id = handle.id().protocol_id();
|
let id = handle.id().protocol_id();
|
||||||
let app_id = self.foreign_toplevel_list.info( &handle )
|
let app_id = toplevel_display_id( &self.foreign_toplevel_list, &handle );
|
||||||
.map( |i| i.app_id )
|
|
||||||
.unwrap_or_default();
|
|
||||||
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } )
|
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
debug_layout,
|
debug_layout,
|
||||||
pending_msgs: Vec::new(),
|
pending_msgs: Vec::new(),
|
||||||
pending_drag_inits: Vec::new(),
|
pending_drag_inits: Vec::new(),
|
||||||
|
tooltip_pending: None,
|
||||||
|
tooltip_visible: None,
|
||||||
qh: qh.clone(),
|
qh: qh.clone(),
|
||||||
last_pointer_serial: 0,
|
last_pointer_serial: 0,
|
||||||
last_input_serial: 0,
|
last_input_serial: 0,
|
||||||
@@ -374,7 +376,11 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
// at a fixed rate. The one exception is a pending long-press:
|
// at a fixed rate. The one exception is a pending long-press:
|
||||||
// cap the wait at its deadline so a perfectly still press still
|
// cap the wait at its deadline so a perfectly still press still
|
||||||
// fires on time.
|
// fires on time.
|
||||||
let timeout = data.next_long_press_wakeup();
|
let timeout = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
|
||||||
|
{
|
||||||
|
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
|
||||||
|
( a, b ) => a.or( b ),
|
||||||
|
};
|
||||||
match event_loop.dispatch( timeout, &mut data )
|
match event_loop.dispatch( timeout, &mut data )
|
||||||
{
|
{
|
||||||
Ok( () ) => {},
|
Ok( () ) => {},
|
||||||
@@ -404,6 +410,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
// emits its stored message and flips into drag mode for the rest
|
// emits its stored message and flips into drag mode for the rest
|
||||||
// of the gesture.
|
// of the gesture.
|
||||||
data.check_long_press_deadlines();
|
data.check_long_press_deadlines();
|
||||||
|
data.check_tooltip_deadline();
|
||||||
|
|
||||||
// Poll external messages (immediate async results, e.g. PAM auth channel)
|
// Poll external messages (immediate async results, e.g. PAM auth channel)
|
||||||
let ext: Vec<_> = data.app.poll_external();
|
let ext: Vec<_> = data.app.poll_external();
|
||||||
@@ -491,7 +498,9 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
|||||||
}
|
}
|
||||||
if data.overlays_dirty
|
if data.overlays_dirty
|
||||||
{
|
{
|
||||||
data.cached_overlays = Some( data.app.overlays() );
|
let mut specs = data.app.overlays();
|
||||||
|
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
|
||||||
|
data.cached_overlays = Some( specs );
|
||||||
data.overlays_dirty = false;
|
data.overlays_dirty = false;
|
||||||
}
|
}
|
||||||
draw_frame( &mut data );
|
draw_frame( &mut data );
|
||||||
@@ -615,7 +624,8 @@ pub fn diff_overlay_ids(
|
|||||||
/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up.
|
/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up.
|
||||||
fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||||
{
|
{
|
||||||
let specs = data.app.overlays();
|
let mut specs = data.app.overlays();
|
||||||
|
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
|
||||||
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
|
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
|
||||||
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
|
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
|
||||||
|
|
||||||
@@ -863,6 +873,7 @@ fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
|||||||
}
|
}
|
||||||
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
|
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
|
||||||
ss.last_requested_size = spec.size;
|
ss.last_requested_size = spec.size;
|
||||||
|
ss.layer_anchor = Some( spec.anchor );
|
||||||
overlays_m.insert( spec.id, ss );
|
overlays_m.insert( spec.id, ss );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,26 @@ impl<A: App> AppData<A>
|
|||||||
|
|
||||||
impl<A: App> AppData<A>
|
impl<A: App> AppData<A>
|
||||||
{
|
{
|
||||||
|
/// Top-left of `focus` in main-surface (global) coordinates. Used to
|
||||||
|
/// translate per-surface pointer coords into a single coordinate
|
||||||
|
/// space the app can reason about during drag-and-drop across
|
||||||
|
/// surfaces.
|
||||||
|
pub( crate ) fn surface_offset_for( &self, focus: SurfaceFocus ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let SurfaceFocus::Overlay( id ) = focus else { return ( 0.0, 0.0 ); };
|
||||||
|
let Some( ss ) = self.overlays.get( &id ) else { return ( 0.0, 0.0 ); };
|
||||||
|
let Some( anchor ) = ss.layer_anchor else { return ( 0.0, 0.0 ); };
|
||||||
|
let ( sw, sh ) = ( self.main.width as f32, self.main.height as f32 );
|
||||||
|
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
|
||||||
|
let x = if anchor.left { 0.0 }
|
||||||
|
else if anchor.right { sw - w }
|
||||||
|
else { ( sw - w ) / 2.0 };
|
||||||
|
let y = if anchor.top { 0.0 }
|
||||||
|
else if anchor.bottom { sh - h }
|
||||||
|
else { ( sh - h ) / 2.0 };
|
||||||
|
( x, y )
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply the outcome of a motion event. Non-blocking side-effects
|
/// Apply the outcome of a motion event. Non-blocking side-effects
|
||||||
/// only: push messages, call app callbacks, set redraw flags.
|
/// only: push messages, call app callbacks, set redraw flags.
|
||||||
pub( super ) fn apply_move_outcome
|
pub( super ) fn apply_move_outcome
|
||||||
@@ -79,10 +99,15 @@ impl<A: App> AppData<A>
|
|||||||
MoveOutcome::Idle => {}
|
MoveOutcome::Idle => {}
|
||||||
MoveOutcome::Drag { pos } =>
|
MoveOutcome::Drag { pos } =>
|
||||||
{
|
{
|
||||||
self.app.on_drag_move( pos.x, pos.y );
|
let ( ox, oy ) = self.surface_offset_for( focus );
|
||||||
|
self.app.on_drag_move( pos.x + ox, pos.y + oy );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
self.surface_mut( focus ).request_redraw();
|
self.surface_mut( focus ).request_redraw();
|
||||||
self.main.request_redraw();
|
self.main.request_redraw();
|
||||||
|
for ss in self.overlays.values_mut()
|
||||||
|
{
|
||||||
|
ss.request_redraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MoveOutcome::Slider { msg } =>
|
MoveOutcome::Slider { msg } =>
|
||||||
{
|
{
|
||||||
@@ -141,7 +166,8 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
ReleaseEvent::Drop { pos } =>
|
ReleaseEvent::Drop { pos } =>
|
||||||
{
|
{
|
||||||
if let Some( msg ) = self.app.on_drop( pos.x, pos.y )
|
let ( ox, oy ) = self.surface_offset_for( focus );
|
||||||
|
if let Some( msg ) = self.app.on_drop( pos.x + ox, pos.y + oy )
|
||||||
{
|
{
|
||||||
self.pending_msgs.push( msg );
|
self.pending_msgs.push( msg );
|
||||||
}
|
}
|
||||||
@@ -149,6 +175,10 @@ impl<A: App> AppData<A>
|
|||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
self.main.request_redraw();
|
self.main.request_redraw();
|
||||||
self.main.frame_pending = false;
|
self.main.frame_pending = false;
|
||||||
|
for ss in self.overlays.values_mut()
|
||||||
|
{
|
||||||
|
ss.request_redraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ReleaseEvent::SwipeLeft =>
|
ReleaseEvent::SwipeLeft =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -641,6 +641,7 @@ mod tests
|
|||||||
handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false },
|
handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false },
|
||||||
keyboard_focusable: true,
|
keyboard_focusable: true,
|
||||||
cursor: crate::types::CursorShape::Default,
|
cursor: crate::types::CursorShape::Default,
|
||||||
|
tooltip: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,10 +84,17 @@ impl<A: App> PointerHandler for AppData<A>
|
|||||||
{
|
{
|
||||||
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|
||||||
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
|
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
|
||||||
|
{
|
||||||
let ss = self.surface_mut( focus );
|
let ss = self.surface_mut( focus );
|
||||||
ss.hovered_idx = new_hover;
|
ss.hovered_idx = new_hover;
|
||||||
if redraw { ss.needs_redraw = true; }
|
if redraw { ss.needs_redraw = true; }
|
||||||
}
|
}
|
||||||
|
match new_hover
|
||||||
|
{
|
||||||
|
Some( idx ) => self.arm_tooltip( focus, idx ),
|
||||||
|
None => self.cancel_tooltip(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mouse drag-promotion: a left-button press whose hit
|
// Mouse drag-promotion: a left-button press whose hit
|
||||||
// widget carries `on_drag_start` should arm a drag as
|
// widget carries `on_drag_start` should arm a drag as
|
||||||
@@ -137,7 +144,8 @@ impl<A: App> PointerHandler for AppData<A>
|
|||||||
( m, o )
|
( m, o )
|
||||||
};
|
};
|
||||||
self.app.update( ds_msg );
|
self.app.update( ds_msg );
|
||||||
self.app.on_drag_move( origin.x, origin.y );
|
let ( ox, oy ) = self.surface_offset_for( focus );
|
||||||
|
self.app.on_drag_move( origin.x + ox, origin.y + oy );
|
||||||
self.dirty_caches();
|
self.dirty_caches();
|
||||||
self.stop_button_repeat();
|
self.stop_button_repeat();
|
||||||
}
|
}
|
||||||
@@ -228,6 +236,7 @@ impl<A: App> PointerHandler for AppData<A>
|
|||||||
{
|
{
|
||||||
self.last_pointer_serial = serial;
|
self.last_pointer_serial = serial;
|
||||||
self.last_input_serial = serial;
|
self.last_input_serial = serial;
|
||||||
|
self.cancel_tooltip();
|
||||||
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
|
||||||
self.pointer_pos = pos;
|
self.pointer_pos = pos;
|
||||||
self.app.on_pointer_move( pos.x, pos.y );
|
self.app.on_pointer_move( pos.x, pos.y );
|
||||||
|
|||||||
@@ -43,7 +43,17 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{ Path, PathBuf };
|
use std::path::{ Path, PathBuf };
|
||||||
use std::sync::{ Arc, Mutex, RwLock };
|
use std::sync::{ Arc, Mutex, OnceLock, RwLock };
|
||||||
|
|
||||||
|
fn system_fontdb() -> Arc<resvg::usvg::fontdb::Database>
|
||||||
|
{
|
||||||
|
static DB: OnceLock<Arc<resvg::usvg::fontdb::Database>> = OnceLock::new();
|
||||||
|
DB.get_or_init( || {
|
||||||
|
let mut db = resvg::usvg::fontdb::Database::new();
|
||||||
|
db.load_system_fonts();
|
||||||
|
Arc::new( db )
|
||||||
|
} ).clone()
|
||||||
|
}
|
||||||
|
|
||||||
use serde::{ Deserialize, Serialize };
|
use serde::{ Deserialize, Serialize };
|
||||||
|
|
||||||
@@ -897,6 +907,7 @@ pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc<Vec<u8>>,
|
|||||||
{
|
{
|
||||||
opts.default_size = ds;
|
opts.default_size = ds;
|
||||||
}
|
}
|
||||||
|
opts.fontdb = system_fontdb();
|
||||||
let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?;
|
let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?;
|
||||||
let svg_size = tree.size();
|
let svg_size = tree.size();
|
||||||
let longest = svg_size.width().max( svg_size.height() );
|
let longest = svg_size.width().max( svg_size.height() );
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ pub struct Button<Msg: Clone>
|
|||||||
/// cancel, and on long-press promotion. Default `false` — most
|
/// cancel, and on long-press promotion. Default `false` — most
|
||||||
/// buttons fire on tap only.
|
/// buttons fire on tap only.
|
||||||
pub repeating: bool,
|
pub repeating: bool,
|
||||||
|
pub tooltip: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Button<Msg>
|
impl<Msg: Clone> Button<Msg>
|
||||||
@@ -147,9 +148,17 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
focusable: true,
|
focusable: true,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
repeating: false,
|
repeating: false,
|
||||||
|
tooltip: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hint shown after a 600 ms pointer dwell. Pointer-only.
|
||||||
|
pub fn tooltip( mut self, text: impl Into<String> ) -> Self
|
||||||
|
{
|
||||||
|
self.tooltip = Some( text.into() );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Override the pointer cursor shape shown on hover.
|
/// Override the pointer cursor shape shown on hover.
|
||||||
pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
|
pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self
|
||||||
{
|
{
|
||||||
@@ -174,6 +183,7 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
focusable: true,
|
focusable: true,
|
||||||
cursor: None,
|
cursor: None,
|
||||||
repeating: false,
|
repeating: false,
|
||||||
|
tooltip: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +494,7 @@ impl<Msg: Clone> Button<Msg>
|
|||||||
focusable: self.focusable,
|
focusable: self.focusable,
|
||||||
cursor: self.cursor,
|
cursor: self.cursor,
|
||||||
repeating: self.repeating,
|
repeating: self.repeating,
|
||||||
|
tooltip: self.tooltip,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,6 +539,45 @@ mod tests
|
|||||||
assert!( b.on_press.is_none() );
|
assert!( b.on_press.is_none() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn tooltip_none_by_default()
|
||||||
|
{
|
||||||
|
let b = Button::<()>::new( "ok".into() );
|
||||||
|
assert!( b.tooltip.is_none() );
|
||||||
|
let b = Button::<()>::new_icon( Arc::new( vec![] ), 0, 0 );
|
||||||
|
assert!( b.tooltip.is_none() );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn tooltip_builder_sets_text()
|
||||||
|
{
|
||||||
|
let b = Button::<()>::new( "ok".into() ).tooltip( "Save document" );
|
||||||
|
assert_eq!( b.tooltip.as_deref(), Some( "Save document" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn tooltip_survives_map_msg()
|
||||||
|
{
|
||||||
|
let b = Button::<()>::new( "ok".into() ).tooltip( "Hint" );
|
||||||
|
let f: super::super::MapFn<(), u32> = std::sync::Arc::new( |()| 0u32 );
|
||||||
|
let mapped = b.map_msg( &f );
|
||||||
|
assert_eq!( mapped.tooltip.as_deref(), Some( "Hint" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn element_tooltip_returns_button_tooltip()
|
||||||
|
{
|
||||||
|
let e: super::super::Element<()> = Button::<()>::new( "ok".into() ).tooltip( "Hi" ).into_element();
|
||||||
|
assert_eq!( e.tooltip(), Some( "Hi" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn element_tooltip_none_for_non_button_widgets()
|
||||||
|
{
|
||||||
|
let t: super::super::Element<()> = super::super::text( "label" ).into();
|
||||||
|
assert!( t.tooltip().is_none() );
|
||||||
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn on_press_maybe_none_leaves_disabled()
|
fn on_press_maybe_none_leaves_disabled()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ pub struct LaidOutWidget<Msg: Clone>
|
|||||||
/// dispatch can pick the right shape without re-walking the
|
/// dispatch can pick the right shape without re-walking the
|
||||||
/// element tree.
|
/// element tree.
|
||||||
pub cursor: crate::types::CursorShape,
|
pub cursor: crate::types::CursorShape,
|
||||||
|
pub tooltip: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> Clone for LaidOutWidget<Msg>
|
impl<Msg: Clone> Clone for LaidOutWidget<Msg>
|
||||||
@@ -438,6 +439,7 @@ impl<Msg: Clone> Clone for LaidOutWidget<Msg>
|
|||||||
handlers: self.handlers.clone(),
|
handlers: self.handlers.clone(),
|
||||||
keyboard_focusable: self.keyboard_focusable,
|
keyboard_focusable: self.keyboard_focusable,
|
||||||
cursor: self.cursor,
|
cursor: self.cursor,
|
||||||
|
tooltip: self.tooltip.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,6 +666,16 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add an arm here to opt a widget kind into the auto-tooltip flow.
|
||||||
|
pub fn tooltip( &self ) -> Option<&str>
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Element::Button( b ) => b.tooltip.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot the widget's callbacks/value into a [`WidgetHandlers`] for
|
/// Snapshot the widget's callbacks/value into a [`WidgetHandlers`] for
|
||||||
/// O(1) dispatch later. Called once per focusable leaf during the layout
|
/// O(1) dispatch later. Called once per focusable leaf during the layout
|
||||||
/// pass; the handlers are then stored alongside the rect in
|
/// pass; the handlers are then stored alongside the rect in
|
||||||
|
|||||||
@@ -219,8 +219,8 @@
|
|||||||
"slots": {
|
"slots": {
|
||||||
"bg-page": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/bg" } },
|
"bg-page": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/bg" } },
|
||||||
"surface": { "type": "color", "value": "@midnight/EE", "meta": { "semantic": "palette/surface" } },
|
"surface": { "type": "color", "value": "@midnight/EE", "meta": { "semantic": "palette/surface" } },
|
||||||
"surface-alt": { "type": "color", "value": "@indigo/D9", "meta": { "semantic": "palette/surface_alt" } },
|
"surface-alt": { "type": "color", "value": "@white/D9", "meta": { "semantic": "palette/surface_alt" } },
|
||||||
"text-primary": { "type": "color", "value": "@white", "meta": { "semantic": "palette/text_primary" } },
|
"text-primary": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/text_primary" } },
|
||||||
"text-secondary": { "type": "color", "value": "@white/B3", "meta": { "semantic": "palette/text_secondary" } },
|
"text-secondary": { "type": "color", "value": "@white/B3", "meta": { "semantic": "palette/text_secondary" } },
|
||||||
"accent": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/accent" } },
|
"accent": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/accent" } },
|
||||||
"divider": { "type": "color", "value": "@white/14", "meta": { "semantic": "palette/divider" } },
|
"divider": { "type": "color", "value": "@white/14", "meta": { "semantic": "palette/divider" } },
|
||||||
|
|||||||
Reference in New Issue
Block a user