From bfe27b6fef56b14d1b82a0b7467b10cc0911b857 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 14 May 2026 22:36:17 +0200 Subject: [PATCH] =?UTF-8?q?event=5Floop,=20widget,=20input:=20pointer-dwel?= =?UTF-8?q?l=20tooltips,=20global=20drag=20coords,=20foreign-toplevel=20na?= =?UTF-8?q?me=20cascade=20`Button::tooltip(=20text=20)`=20registers=20a=20?= =?UTF-8?q?hint=20string=20that=20fires=20after=20a=20600=20ms=20pointer?= =?UTF-8?q?=20dwell.=20`LaidOutWidget`=20gains=20a=20`tooltip:=20Option`=20field,=20`Element::tooltip()`=20exposes=20it=20to=20th?= =?UTF-8?q?e=20input=20layer,=20and=20the=20existing=20pointer-hover=20pat?= =?UTF-8?q?h=20now=20calls=20`arm=5Ftooltip`=20on=20hover-enter=20and=20`c?= =?UTF-8?q?ancel=5Ftooltip`=20on=20hover-leave=20/=20touch.=20The=20deadli?= =?UTF-8?q?ne=20is=20polled=20alongside=20`next=5Flong=5Fpress=5Fwakeup`?= =?UTF-8?q?=20in=20`try=5Frun`=20so=20an=20idle=20pointer=20still=20gets?= =?UTF-8?q?=20a=20wake-up=20at=20the=20firing=20instant;=20on=20fire,=20`t?= =?UTF-8?q?ooltip=5Foverlay()`=20synthesises=20an=20`OverlaySpec`=20?= =?UTF-8?q?=E2=80=94=20a=20rounded=20`text=5Fprimary=20@=2095%`=20pill=20d?= =?UTF-8?q?rawn=20with=20`bg`=20text=20=E2=80=94=20anchored=20above=20the?= =?UTF-8?q?=20hovered=20widget,=20flipping=20below=20or=20clamping=20insid?= =?UTF-8?q?e=20the=20screen=20if=20it=20would=20clip,=20and=20pushed=20alo?= =?UTF-8?q?ngside=20the=20app's=20own=20overlays=20both=20in=20the=20redra?= =?UTF-8?q?w=20path=20and=20in=20`reconcile=5Foverlays`=20so=20the=20layer?= =?UTF-8?q?=20surface=20is=20created=20the=20same=20frame=20the=20tooltip?= =?UTF-8?q?=20becomes=20visible.=20Pointer-only=20by=20design:=20touch=20e?= =?UTF-8?q?vents=20explicitly=20cancel=20because=20a=20tap-and-release=20s?= =?UTF-8?q?hould=20never=20linger=20into=20a=20hint.=20The=20`showcase`=20?= =?UTF-8?q?example=20wires=20`.tooltip(..)`=20on=20the=20three=20button=20?= =?UTF-8?q?variants=20as=20a=20smoke=20test.=20The=20drag=20pipeline=20now?= =?UTF-8?q?=20reports=20positions=20in=20main-surface=20(global)=20coordin?= =?UTF-8?q?ates=20instead=20of=20per-surface.=20`surface=5Foffset=5Ffor(?= =?UTF-8?q?=20focus=20)`=20derives=20the=20top-left=20of=20an=20overlay=20?= =?UTF-8?q?surface=20from=20`SurfaceState::layer=5Fanchor`=20=E2=80=94=20n?= =?UTF-8?q?ewly=20stored=20at=20`reconcile=5Foverlays`=20time=20from=20`Ov?= =?UTF-8?q?erlaySpec::anchor`=20=E2=80=94=20combined=20with=20the=20main?= =?UTF-8?q?=20surface's=20dimensions;=20`on=5Fdrag=5Fmove`,=20`on=5Fdrop`,?= =?UTF-8?q?=20the=20synthetic=20move=20emitted=20on=20drag-promotion=20in?= =?UTF-8?q?=20`pointer.rs`,=20and=20the=20`pending=5Fdrag=5Finits`=20push?= =?UTF-8?q?=20site=20in=20`gesture::start=5Fdrag`=20all=20translate=20befo?= =?UTF-8?q?re=20handing=20coordinates=20to=20the=20app.=20The=20motion=20a?= =?UTF-8?q?nd=20release=20paths=20additionally=20`request=5Fredraw()`=20ev?= =?UTF-8?q?ery=20overlay=20so=20a=20dock-style=20drop=20target=20painted?= =?UTF-8?q?=20on=20an=20`Anchor::Bottom`=20layer=20surface=20gets=20repain?= =?UTF-8?q?ted=20as=20the=20drag=20moves=20=E2=80=94=20without=20that,=20t?= =?UTF-8?q?he=20visible=20drop=20indicator=20only=20updates=20when=20the?= =?UTF-8?q?=20cursor=20re-enters=20the=20main=20surface.=20Drops=20still?= =?UTF-8?q?=20target=20whichever=20surface=20fired=20the=20release;=20only?= =?UTF-8?q?=20the=20coordinates=20are=20unified.=20`ForeignToplevelListHan?= =?UTF-8?q?dler`=20previously=20read=20`app=5Fid`=20directly=20via=20`Fore?= =?UTF-8?q?ignToplevelList::info()`.=20Clients=20that=20never=20set=20`app?= =?UTF-8?q?=5Fid`=20(some=20winit-windowed=20compositors,=20simple=20test?= =?UTF-8?q?=20clients)=20were=20silently=20invisible=20to=20crustace-style?= =?UTF-8?q?=20docks=20because=20the=20empty=20string=20fell=20through=20`u?= =?UTF-8?q?nwrap=5For=5Fdefault()`=20and=20the=20dock=20then=20keyed=20ent?= =?UTF-8?q?ries=20off=20`""`.=20`toplevel=5Fdisplay=5Fid()`=20cascades:=20?= =?UTF-8?q?prefer=20`app=5Fid`=20for=20desktop-entry=20matching,=20fall=20?= =?UTF-8?q?back=20to=20`title`=20for=20human-readable=20identification,=20?= =?UTF-8?q?and=20finally=20to=20the=20protocol-issued=20`identifier`=20whi?= =?UTF-8?q?ch=20is=20always=20present=20and=20unique=20per=20handle.=20App?= =?UTF-8?q?lied=20to=20both=20`new=5Ftoplevel`=20and=20`update=5Ftoplevel`?= =?UTF-8?q?.=20`theme::system=5Ffontdb()`=20lazily=20loads=20the=20system?= =?UTF-8?q?=20font=20database=20once=20via=20`OnceLock`=20and=20reuses=20t?= =?UTF-8?q?he=20`Arc`=20for=20every=20`decode=5Fsvg=5Fbytes`=20call.=20res?= =?UTF-8?q?vg's=20default=20`Options::fontdb`=20is=20empty,=20so=20any=20S?= =?UTF-8?q?VG=20containing=20``=20rendered=20with=20the=20built-in?= =?UTF-8?q?=20fallback=20font=20or=20no=20font=20at=20all;=20with=20the=20?= =?UTF-8?q?system=20DB=20attached,=20icons=20and=20decorative=20SVGs=20wit?= =?UTF-8?q?h=20embedded=20labels=20now=20resolve=20glyphs=20correctly.=20C?= =?UTF-8?q?ached=20because=20`load=5Fsystem=5Ffonts()`=20walks=20every=20f?= =?UTF-8?q?ont=20path=20on=20the=20system=20and=20is=20comfortably=20tens?= =?UTF-8?q?=20of=20milliseconds=20on=20a=20cold=20cache=20=E2=80=94=20not?= =?UTF-8?q?=20something=20to=20repeat=20per=20icon=20decode.=20`themes/def?= =?UTF-8?q?ault/theme.json`=20tweaks=20one=20variant's=20slot=20palette:?= =?UTF-8?q?=20`surface-alt`=20from=20`@indigo/D9`=20to=20`@white/D9`=20and?= =?UTF-8?q?=20`text-primary`=20from=20`@white`=20to=20`@navy`,=20plus=20a?= =?UTF-8?q?=20cosmetic=20re-alignment=20of=20the=20`"value"`=20columns=20i?= =?UTF-8?q?n=20the=20same=20slots=20block.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/showcase.rs | 3 + src/draw/damage.rs | 1 + src/draw/layout.rs | 2 + src/event_loop/app_data.rs | 143 ++++++++++++++++++++++++++++++++++++- src/event_loop/handlers.rs | 25 +++++-- src/event_loop/mod.rs | 17 ++++- src/input/dispatch.rs | 34 ++++++++- src/input/gesture.rs | 1 + src/input/pointer.rs | 17 +++-- src/theme/mod.rs | 13 +++- src/widget/button.rs | 50 +++++++++++++ src/widget/mod.rs | 12 ++++ themes/default/theme.json | 14 ++-- 13 files changed, 308 insertions(+), 24 deletions(-) diff --git a/examples/showcase.rs b/examples/showcase.rs index 3b3bfeb..ad01a1c 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -97,16 +97,19 @@ impl App for ShowcaseApp button( "Primary" ) .variant( ButtonVariant::Primary ) .id( Self::SAVE_BUTTON_ID ) + .tooltip( "Hover-dwell tooltip via .tooltip()" ) .on_press( Message::Pressed( "Primary" ) ), ) .push( button( "Secondary" ) .variant( ButtonVariant::Secondary ) + .tooltip( "Secondary action" ) .on_press( Message::Pressed( "Secondary" ) ), ) .push( button( "Tertiary" ) .variant( ButtonVariant::Tertiary ) + .tooltip( "Tertiary action" ) .on_press( Message::Pressed( "Tertiary" ) ), ); diff --git a/src/draw/damage.rs b/src/draw/damage.rs index 32e8a9b..de53493 100644 --- a/src/draw/damage.rs +++ b/src/draw/damage.rs @@ -233,6 +233,7 @@ mod tests handlers: WidgetHandlers::None, keyboard_focusable: true, cursor: crate::types::CursorShape::Default, + tooltip: None, } } diff --git a/src/draw/layout.rs b/src/draw/layout.rs index a85d07d..e0ba212 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -129,6 +129,7 @@ pub( crate ) fn layout_and_draw( }, keyboard_focusable: false, cursor: p.cursor.unwrap_or( crate::types::CursorShape::Pointer ), + tooltip: None, } ); } layout_and_draw::( p.child.as_ref(), canvas, rect, ctx, my_idx + 1 ) @@ -378,6 +379,7 @@ pub( crate ) fn layout_and_draw( handlers: other.handlers(), keyboard_focusable: other.is_focusable(), cursor: other.cursor_shape(), + tooltip: other.tooltip().map( str::to_string ), } ); } flat_idx + 1 diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index c7ea1b4..a563098 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -239,6 +239,26 @@ pub( crate ) enum SurfaceFocus 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 /// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay /// returned by [`crate::app::App::overlays`]. @@ -415,6 +435,7 @@ pub( crate ) struct SurfaceState /// for slide-down / grow animations). `(0, 0)` for non-layer surfaces /// and for layer surfaces before their first configure. pub last_requested_size: ( u32, u32 ), + pub layer_anchor: Option, /// Anchor rect the xdg-popup positioner was last configured with. /// `None` for non-popup surfaces. pub last_popup_anchor: Option, @@ -465,6 +486,7 @@ impl SurfaceState titlebar_close_rect: Rect::default(), scale_factor: 1, last_requested_size: ( 0, 0 ), + layer_anchor: None, last_popup_anchor: None, popup_reposition_token: 0, frame_pending: false, @@ -674,6 +696,9 @@ pub struct AppData /// before the user's finger / cursor moves — useful on mouse where the /// pointer might sit perfectly still between press and drag. pub pending_drag_inits: Vec, + + pub tooltip_pending: Option, + pub tooltip_visible: Option, pub qh: QueueHandle, /// Last pointer serial (needed for interactive move). pub last_pointer_serial: u32, @@ -1894,6 +1919,120 @@ impl AppData 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 + { + 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> + { + 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 = 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 = 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 /// elapsed. Idempotent — if a press is in-flight but not yet due this /// does nothing. On fire, the message is pushed to `pending_msgs`, @@ -1960,7 +2099,9 @@ impl AppData if let Some( m ) = ds_msg { 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 // gesture has switched semantics and the timer has // nothing to fire against any more. diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 6993df0..117c575 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -412,6 +412,23 @@ impl ProvidesRegistryState for AppData // handle proxy — unique per session and stable for the handle's // lifetime, the same value paired across `Opened` and the matching // `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 ForeignToplevelListHandler for AppData { fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList @@ -427,9 +444,7 @@ impl ForeignToplevelListHandler for AppData ) { let id = handle.id().protocol_id(); - let app_id = self.foreign_toplevel_list.info( &handle ) - .map( |i| i.app_id ) - .unwrap_or_default(); + let app_id = toplevel_display_id( &self.foreign_toplevel_list, &handle ); if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } ) { self.pending_msgs.push( msg ); @@ -451,9 +466,7 @@ impl ForeignToplevelListHandler for AppData // but the app_id-can-change-mid-life case still produces a // correct sequence (old refcount drops, new one bumps). let id = handle.id().protocol_id(); - let app_id = self.foreign_toplevel_list.info( &handle ) - .map( |i| i.app_id ) - .unwrap_or_default(); + let app_id = toplevel_display_id( &self.foreign_toplevel_list, &handle ); if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } ) { self.pending_msgs.push( msg ); diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 2ccea1c..fcd5d1b 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -304,6 +304,8 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> debug_layout, pending_msgs: Vec::new(), pending_drag_inits: Vec::new(), + tooltip_pending: None, + tooltip_visible: None, qh: qh.clone(), last_pointer_serial: 0, last_input_serial: 0, @@ -374,7 +376,11 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> // at a fixed rate. The one exception is a pending long-press: // cap the wait at its deadline so a perfectly still press still // 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 ) { Ok( () ) => {}, @@ -404,6 +410,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> // emits its stored message and flips into drag mode for the rest // of the gesture. data.check_long_press_deadlines(); + data.check_tooltip_deadline(); // Poll external messages (immediate async results, e.g. PAM auth channel) let ext: Vec<_> = data.app.poll_external(); @@ -491,7 +498,9 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> } 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; } draw_frame( &mut data ); @@ -615,7 +624,8 @@ pub fn diff_overlay_ids( /// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up. fn reconcile_overlays( data: &mut AppData ) { - 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 wanted: HashSet = next_ids.iter().copied().collect(); @@ -863,6 +873,7 @@ fn reconcile_overlays( data: &mut AppData ) } let mut ss = SurfaceState::::new( surface, 0.0, String::new() ); ss.last_requested_size = spec.size; + ss.layer_anchor = Some( spec.anchor ); overlays_m.insert( spec.id, ss ); } } diff --git a/src/input/dispatch.rs b/src/input/dispatch.rs index 651bd6c..cc740be 100644 --- a/src/input/dispatch.rs +++ b/src/input/dispatch.rs @@ -65,6 +65,26 @@ impl AppData impl AppData { + /// 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 /// only: push messages, call app callbacks, set redraw flags. pub( super ) fn apply_move_outcome @@ -79,10 +99,15 @@ impl AppData MoveOutcome::Idle => {} 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.surface_mut( focus ).request_redraw(); self.main.request_redraw(); + for ss in self.overlays.values_mut() + { + ss.request_redraw(); + } } MoveOutcome::Slider { msg } => { @@ -141,7 +166,8 @@ impl AppData { 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 ); } @@ -149,6 +175,10 @@ impl AppData self.dirty_caches(); self.main.request_redraw(); self.main.frame_pending = false; + for ss in self.overlays.values_mut() + { + ss.request_redraw(); + } } ReleaseEvent::SwipeLeft => { diff --git a/src/input/gesture.rs b/src/input/gesture.rs index 51cc3fd..6fe8e79 100644 --- a/src/input/gesture.rs +++ b/src/input/gesture.rs @@ -641,6 +641,7 @@ mod tests handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false }, keyboard_focusable: true, cursor: crate::types::CursorShape::Default, + tooltip: None, } } diff --git a/src/input/pointer.rs b/src/input/pointer.rs index 2068615..8af6b64 100644 --- a/src/input/pointer.rs +++ b/src/input/pointer.rs @@ -84,9 +84,16 @@ impl PointerHandler for AppData { let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover ) || hover_affects_paint( &self.surface( focus ).widget_rects, new_hover ); - let ss = self.surface_mut( focus ); - ss.hovered_idx = new_hover; - if redraw { ss.needs_redraw = true; } + { + let ss = self.surface_mut( focus ); + ss.hovered_idx = new_hover; + 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 @@ -137,7 +144,8 @@ impl PointerHandler for AppData ( m, o ) }; 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.stop_button_repeat(); } @@ -228,6 +236,7 @@ impl PointerHandler for AppData { self.last_pointer_serial = serial; self.last_input_serial = serial; + self.cancel_tooltip(); let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 ); self.pointer_pos = pos; self.app.on_pointer_move( pos.x, pos.y ); diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 0995255..abe6e38 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -43,7 +43,17 @@ use std::collections::HashMap; use std::path::{ Path, PathBuf }; -use std::sync::{ Arc, Mutex, RwLock }; +use std::sync::{ Arc, Mutex, OnceLock, RwLock }; + +fn system_fontdb() -> Arc +{ + static DB: OnceLock> = 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 }; @@ -897,6 +907,7 @@ pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc>, { opts.default_size = ds; } + opts.fontdb = system_fontdb(); let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?; let svg_size = tree.size(); let longest = svg_size.width().max( svg_size.height() ); diff --git a/src/widget/button.rs b/src/widget/button.rs index 15b91a1..ba349a1 100644 --- a/src/widget/button.rs +++ b/src/widget/button.rs @@ -128,6 +128,7 @@ pub struct Button /// cancel, and on long-press promotion. Default `false` — most /// buttons fire on tap only. pub repeating: bool, + pub tooltip: Option, } impl Button @@ -147,9 +148,17 @@ impl Button focusable: true, cursor: None, repeating: false, + tooltip: None, } } + /// Hint shown after a 600 ms pointer dwell. Pointer-only. + pub fn tooltip( mut self, text: impl Into ) -> Self + { + self.tooltip = Some( text.into() ); + self + } + /// Override the pointer cursor shape shown on hover. pub fn cursor( mut self, shape: crate::types::CursorShape ) -> Self { @@ -174,6 +183,7 @@ impl Button focusable: true, cursor: None, repeating: false, + tooltip: None, } } @@ -484,6 +494,7 @@ impl Button focusable: self.focusable, cursor: self.cursor, repeating: self.repeating, + tooltip: self.tooltip, } } } @@ -528,6 +539,45 @@ mod tests 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 ] fn on_press_maybe_none_leaves_disabled() { diff --git a/src/widget/mod.rs b/src/widget/mod.rs index b7ef607..b588104 100755 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -423,6 +423,7 @@ pub struct LaidOutWidget /// dispatch can pick the right shape without re-walking the /// element tree. pub cursor: crate::types::CursorShape, + pub tooltip: Option, } impl Clone for LaidOutWidget @@ -438,6 +439,7 @@ impl Clone for LaidOutWidget handlers: self.handlers.clone(), keyboard_focusable: self.keyboard_focusable, cursor: self.cursor, + tooltip: self.tooltip.clone(), } } } @@ -664,6 +666,16 @@ impl Element } } + /// 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 /// O(1) dispatch later. Called once per focusable leaf during the layout /// pass; the handlers are then stored alongside the rect in diff --git a/themes/default/theme.json b/themes/default/theme.json index 1442a7c..5c3c59a 100644 --- a/themes/default/theme.json +++ b/themes/default/theme.json @@ -217,14 +217,14 @@ "focus_ring": "@teal" }, "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-alt": { "type": "color", "value": "@indigo/D9", "meta": { "semantic": "palette/surface_alt" } }, - "text-primary": { "type": "color", "value": "@white", "meta": { "semantic": "palette/text_primary" } }, - "text-secondary": { "type": "color", "value": "@white/B3", "meta": { "semantic": "palette/text_secondary" } }, - "accent": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/accent" } }, - "divider": { "type": "color", "value": "@white/14", "meta": { "semantic": "palette/divider" } }, - "icon": { "type": "color", "value": "@white", "meta": { "semantic": "palette/icon" } }, + "surface-alt": { "type": "color", "value": "@white/D9", "meta": { "semantic": "palette/surface_alt" } }, + "text-primary": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/text_primary" } }, + "text-secondary": { "type": "color", "value": "@white/B3", "meta": { "semantic": "palette/text_secondary" } }, + "accent": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/accent" } }, + "divider": { "type": "color", "value": "@white/14", "meta": { "semantic": "palette/divider" } }, + "icon": { "type": "color", "value": "@white", "meta": { "semantic": "palette/icon" } }, "shadows-glass": { "type": "shadows",