From 312fb7e0fdd0bb976866912f5b19b5ec9d29517d Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 15 Oct 2026 10:32:50 +0200 Subject: [PATCH] Tooltips in a window are popups beside their widget, theme SVGs with plus-lighter highlights are no longer painted grey, and the folder-files icon is shaped as a folder A tooltip was a layer-shell surface spanning the output, placed at the coordinates of its widget in the main surface. In a layer-shell shell the two share an origin, but an xdg-shell window does not know where it sits on screen, so in a window the tooltip appeared shifted by the window's position, wherever that put it. The application icons of the default theme, exported from Figma, light their glass with `feBlend mode="plus-lighter"`, which resvg does not know and paints as `normal`, so the highlights came out as grey edges; at dock size those edges cover whole pixels and the icon looks dirty. And `archives/folder-files`, the icon applications show for a folder, was a clipboard and a sheet rather than a folder. Tooltips. `tooltip_popup` tells when the tooltip must be an xdg-popup, that is when the main surface is a window and the widget is on it, and gives its anchor rect and logical size. `reconcile_overlays` then creates the tooltip's overlay as a popup child of the window, anchored to the widget's rect, above it with a 6 px gap (`TOOLTIP_GAP`), flipped below or slid sideways when it does not fit, so the compositor puts it beside the widget. It takes no grab and keeps the empty input region, so it never takes the pointer from the widget it describes. Its view is the pill centred in the popup. Layer-shell surfaces keep the previous placement, now in `tooltip_on_screen`. `tooltip_id` and `tooltip_size` hold the id and the estimated size both paths share. `OverlaySpec` is unchanged, so applications that build overlays need no change. Blend modes. `decode_svg_bytes` replaces `plus-lighter` with `screen`, the closest mode resvg supports, before parsing; a document without it is parsed as it comes, without a copy. A test covers both cases. The folder icon. Both variants of `archives/folder-files` are now the outline of `folder-empty` with the sheet of the previous icon inside, placed and sized as the lock of `folder-locked`: drawn in strokes in `line`, cut out of the filled folder in `filled`. --- src/event_loop/overlays_reconcile.rs | 33 ++++++++ src/event_loop/tooltip.rs | 79 +++++++++++++------ src/theme/assets.rs | 36 ++++++++- .../filled/archives/folder-files.svg | 7 +- .../catalogue/line/archives/folder-files.svg | 12 +-- 5 files changed, 133 insertions(+), 34 deletions(-) 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 @@ - - - - - - + + + + + +