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
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

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`.
This commit is contained in:
2026-10-15 10:32:50 +02:00
parent dcb1c7dff3
commit 312fb7e0fd
5 changed files with 133 additions and 34 deletions
+33
View File
@@ -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<A: App>( data: &mut AppData<A> )
{
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<crate::app::OverlayId> = next_ids.iter().copied().collect();
@@ -237,6 +242,34 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
}
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::<A::Message>::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
+56 -23
View File
@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<A: App> AppData<A>
self.main.request_redraw();
}
pub( crate ) fn tooltip_overlay( &self ) -> Option<crate::app::OverlaySpec<A::Message>>
/// 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<crate::app::OverlaySpec<A::Message>>
{
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<A: App> AppData<A>
.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<A: App> AppData<A>
.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<A::Message> = 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<A::Message> = 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<A: App> AppData<A>
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<A::Message>, ( anchor_x, anchor_y, anchor_w, anchor_h ): ( f32, f32, f32, f32 ), text: &str, sf: f32 ) -> crate::widget::Element<A::Message>
{
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 )
}