diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index d5ee1da..c70290e 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -27,6 +27,10 @@ use wayland_protocols::xdg::shell::client::xdg_positioner:: use crate::app::App; use crate::types::{ Length, Rect }; use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; +use super::tooltip::tooltip_id; + +/// Logical pixels between a tooltip popup and its widget. +const TOOLTIP_GAP: i32 = 6; /// Sync `data.overlays` with the app's current `App::overlays()` spec list: /// destroy any overlay whose id disappeared and create a fresh `SurfaceState` @@ -37,6 +41,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) { let mut specs = data.app.overlays(); if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); } + let tooltip_popup = data.tooltip_popup(); let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect(); let wanted: HashSet = next_ids.iter().copied().collect(); @@ -237,6 +242,34 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) } continue; } + // The tooltip in a window is a popup beside its widget, without a grab so it never takes the pointer from it. + if spec.id == tooltip_id() + { + if let ( Some( ( anchor, ( w, h ) ) ), Some( xdg_shell ), Some( parent ) ) = ( tooltip_popup, xdg_shell_opt, parent_xdg.as_ref() ) + { + let Ok( positioner ) = XdgPositioner::new( xdg_shell ) else { continue }; + positioner.set_size( w.max( 1 ), h.max( 1 ) ); + positioner.set_anchor_rect( + ( anchor.x / parent_scale ).round() as i32, + ( anchor.y / parent_scale ).round() as i32, + ( anchor.width / parent_scale ).round().max( 1.0 ) as i32, + ( anchor.height / parent_scale ).round().max( 1.0 ) as i32, + ); + positioner.set_anchor( PositionerAnchor::Top ); + positioner.set_gravity( Gravity::Top ); + positioner.set_offset( 0, -TOOLTIP_GAP ); + positioner.set_constraint_adjustment( + ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX | ConstraintAdjustment::SlideY, + ); + let Ok( surface ) = Surface::new( cs, qh ) else { continue }; + let Ok( popup ) = Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell ) else { continue }; + popup.wl_surface().commit(); + let mut ss = SurfaceState::::new( SurfaceKind::Popup( popup ), 0.0, String::new() ); + ss.last_requested_size = resolved_size; + overlays_m.insert( spec.id, ss ); + continue; + } + } // `anchor_widget_id = Some(_)` → xdg-popup path; `None` → // wlr-layer-shell path. if let Some( anchor_id ) = spec.anchor_widget_id diff --git a/src/event_loop/tooltip.rs b/src/event_loop/tooltip.rs index 7f2c86e..57683da 100644 --- a/src/event_loop/tooltip.rs +++ b/src/event_loop/tooltip.rs @@ -2,7 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use super::app_data::AppData; -use super::surface::SurfaceFocus; +use super::surface::{ SurfaceFocus, SurfaceKind }; use crate::app::App; use crate::types::{ Length, Rect }; @@ -85,14 +85,20 @@ impl AppData self.main.request_redraw(); } - pub( crate ) fn tooltip_overlay( &self ) -> Option> + /// Anchor rect (physical, in the main surface) and logical size of the tooltip when it is an xdg-popup: in a window the + /// surface's place on screen is unknown, so only the compositor can put the tooltip beside its widget. + pub( crate ) fn tooltip_popup( &self ) -> Option<( Rect, ( i32, i32 ) )> { let v = self.tooltip_visible.as_ref()?; + if v.focus != SurfaceFocus::Main || !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; } + let ( w, h ) = tooltip_size( &v.text ); + Some( ( v.anchor, ( w.ceil() as i32, h.ceil() as i32 ) ) ) + } - 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 ); + pub( crate ) fn tooltip_overlay( &self ) -> Option> + { + let v = self.tooltip_visible.as_ref()?; + let id = tooltip_id(); // Layout runs in the overlay's physical buffer space, so every // coordinate below is physical. The overlay's own scale wins once @@ -101,7 +107,6 @@ impl AppData .map( |ss| ss.scale_factor ) .unwrap_or( self.main.scale_factor ) .max( 1 ) as f32; - let ts = crate::types::text_scale(); let ( ox, oy ) = self.surface_offset_for( v.focus ); let src_scale = self.try_surface( v.focus )?.scale_factor.max( 1 ) as f32; @@ -123,22 +128,14 @@ impl AppData .radius( 8.0 * sf ) .into(); - let estimated_w = ( v.text.chars().count() as f32 * 7.5 * ts * sf + 24.0 * sf ) - .clamp( 40.0 * sf, 320.0 * sf ); - let estimated_h = 16.0 * ts * sf + 12.0 * sf; - let gap = 6.0 * sf; - let margin = 4.0 * sf; - let mut x = anchor_x + ( anchor_w - estimated_w ) / 2.0; - let mut y = anchor_y - estimated_h - gap; - let sw = self.main.width as f32 * sf; - let sh = self.main.height as f32 * sf; - x = x.clamp( margin, ( sw - estimated_w - margin ).max( margin ) ); - if y < margin { y = anchor_y + anchor_h + gap; } - if y + estimated_h > sh - margin { y = ( sh - estimated_h - margin ).max( margin ); } - - 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(); + let view: crate::widget::Element = if self.tooltip_popup().is_some() + { + crate::layout::stack::stack() + .push_aligned( pill, crate::layout::stack::HAlign::Center, crate::layout::stack::VAlign::Center ) + .into() + } else { + self.tooltip_on_screen( pill, ( anchor_x, anchor_y, anchor_w, anchor_h ), &v.text, sf ) + }; Some( crate::app::OverlaySpec { @@ -154,4 +151,40 @@ impl AppData anchor_widget_id: None, } ) } + + /// Above the anchor, or below it near the top, kept within the main surface; for a layer surface, which spans the output. + fn tooltip_on_screen( &self, pill: crate::widget::Element, ( anchor_x, anchor_y, anchor_w, anchor_h ): ( f32, f32, f32, f32 ), text: &str, sf: f32 ) -> crate::widget::Element + { + let ( estimated_w, estimated_h ) = tooltip_size( text ); + let ( estimated_w, estimated_h ) = ( estimated_w * sf, estimated_h * sf ); + let gap = 6.0 * sf; + let margin = 4.0 * sf; + let mut x = anchor_x + ( anchor_w - estimated_w ) / 2.0; + let mut y = anchor_y - estimated_h - gap; + let sw = self.main.width as f32 * sf; + let sh = self.main.height as f32 * sf; + x = x.clamp( margin, ( sw - estimated_w - margin ).max( margin ) ); + if y < margin { y = anchor_y + anchor_h + gap; } + if y + estimated_h > sh - margin { y = ( sh - estimated_h - margin ).max( margin ); } + + crate::layout::stack::stack() + .push_translated( pill, crate::layout::stack::HAlign::Start, crate::layout::stack::VAlign::Top, x, y ) + .into() + } +} + +pub( crate ) fn tooltip_id() -> crate::app::OverlayId +{ + use std::hash::{ Hash, Hasher }; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + "ltk-tooltip".hash( &mut hasher ); + crate::app::OverlayId( hasher.finish() as u32 ) +} + +/// Logical size of the pill, estimated from its text. +fn tooltip_size( text: &str ) -> ( f32, f32 ) +{ + let ts = crate::types::text_scale(); + let w = ( text.chars().count() as f32 * 7.5 * ts + 24.0 ).clamp( 40.0, 320.0 ); + ( w, 16.0 * ts + 12.0 ) } diff --git a/src/theme/assets.rs b/src/theme/assets.rs index 2cd3e0f..fe23508 100644 --- a/src/theme/assets.rs +++ b/src/theme/assets.rs @@ -5,6 +5,7 @@ //! icons, launcher icon, and the SVG rasterisation pipeline (with //! process-wide cache) that the canvas consumes. +use std::borrow::Cow; use std::collections::HashMap; use std::path::{ Path, PathBuf }; use std::sync::{ Arc, Mutex }; @@ -355,7 +356,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 tree = resvg::usvg::Tree::from_data( &supported_blends( svg_bytes ), &opts ).ok()?; let svg_size = tree.size(); let longest = svg_size.width().max( svg_size.height() ); if longest <= 0.0 { return None; } @@ -368,6 +369,30 @@ pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc>, Some( ( Arc::new( pixmap.take() ), w, h ) ) } +/// resvg falls back to `normal` for the CSS-only `plus-lighter` blend, painting highlights grey; `screen` is the closest it supports. +fn supported_blends( svg: &[u8] ) -> Cow<'_, [u8]> +{ + const FROM: &[u8] = b"plus-lighter"; + const TO: &[u8] = b"screen"; + let find = |s: &[u8]| s.windows( FROM.len() ).position( |w| w == FROM ); + let Some( mut at ) = find( svg ) else { return Cow::Borrowed( svg ) }; + let mut out = Vec::with_capacity( svg.len() ); + let mut rest = svg; + loop + { + out.extend_from_slice( &rest[ ..at ] ); + out.extend_from_slice( TO ); + rest = &rest[ at + FROM.len().. ]; + match find( rest ) + { + Some( next ) => at = next, + None => break, + } + } + out.extend_from_slice( rest ); + Cow::Owned( out ) +} + /// Resolve a theme-relative icon name to an absolute path inside the /// active theme's `icons/catalogue/` tree. /// @@ -475,6 +500,15 @@ mod tests assert_eq!( h, 32 ); } + #[ test ] + fn plus_lighter_blends_become_screen() + { + let svg = br#""#; + assert_eq!( &*supported_blends( svg ), br#""# ); + let plain = br#""#; + assert!( matches!( supported_blends( plain ), Cow::Borrowed( _ ) ) ); + } + #[ test ] fn decode_svg_bytes_rejects_garbage() { diff --git a/themes/default/icons/catalogue/filled/archives/folder-files.svg b/themes/default/icons/catalogue/filled/archives/folder-files.svg index 89df752..49f52b5 100644 --- a/themes/default/icons/catalogue/filled/archives/folder-files.svg +++ b/themes/default/icons/catalogue/filled/archives/folder-files.svg @@ -1,6 +1,5 @@ - - - - + + + diff --git a/themes/default/icons/catalogue/line/archives/folder-files.svg b/themes/default/icons/catalogue/line/archives/folder-files.svg index 5cdcad9..e2d6209 100644 --- a/themes/default/icons/catalogue/line/archives/folder-files.svg +++ b/themes/default/icons/catalogue/line/archives/folder-files.svg @@ -1,8 +1,8 @@ - - - - - - + + + + + +