// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use std::collections::HashSet; use smithay_client_toolkit:: { compositor::Surface, shell:: { WaylandSurface, xdg:: { XdgPositioner, XdgSurface, popup::Popup, }, }, }; use wayland_protocols::xdg::shell::client::xdg_positioner:: { Anchor as PositionerAnchor, ConstraintAdjustment, Gravity, }; use crate::app::App; use crate::types::{ Length, Rect }; use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState }; /// Sync `data.overlays` with the app's current `App::overlays()` spec list: /// destroy any overlay whose id disappeared and create a fresh `SurfaceState` /// for every id that just appeared. Created surfaces are materialized /// immediately if an output is already available; otherwise they stay /// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up. 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 next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect(); let wanted: HashSet = next_ids.iter().copied().collect(); // Drop overlays that disappeared from the spec list. If the overlay had // an in-flight drag (long-press fired), migrate that state to `main` so // the motion / release that follows still routes through the app's drag // handlers — apps typically hide the overlay *because* of the long-press, // and we must not lose the drag state with the surface. let removed: Vec<_> = data.overlays.keys() .filter( |id| !wanted.contains( id ) ) .copied() .collect(); for id in removed { if let Some( ss ) = data.overlays.remove( &id ) { if ss.gesture.long_press_fired { data.main.gesture.long_press_fired = true; data.main.gesture.long_press_origin = ss.gesture.long_press_origin; // Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine. if data.main.primary_touch_id.is_none() { data.main.primary_touch_id = ss.primary_touch_id; } } } } // Clear any stale per-device focus pointing at a destroyed overlay. if let SurfaceFocus::Overlay( id ) = data.pointer_focus { if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; } } if let SurfaceFocus::Overlay( id ) = data.keyboard_focus { if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; } } // Rewrite touch_focus entries pointing at a destroyed overlay to Main // rather than dropping them. Dropping would make subsequent motion/up // events for the same touch id default to Main *without* the long-press // drag state, turning a release into a stray tap. for f in data.touch_focus.values_mut() { if let SurfaceFocus::Overlay( id ) = *f { if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; } } } // Create overlays that just appeared. Uses field-level borrow splitting so // the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state` // / `output_state` / `qh` can coexist with the mutable borrow of // `overlays` inside the loop. let layer_shell_opt = data.layer_shell.as_ref(); let xdg_shell_opt = data.xdg_shell.as_ref(); let output_opt = data.output_state.outputs().next(); let cs = &data.compositor_state; let qh = &data.qh; let grab_seat = data.seat_state.seats().next(); let grab_serial = data.last_input_serial; // Snapshot the parent xdg_surface (only an xdg toplevel can host // xdg-popups; layer-shell parents would need a different code path // via `LayerSurface::get_popup`, which we do not currently support). let parent_xdg = match data.main.surface { SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ), _ => None, }; let parent_scale_i = data.main.scale_factor.max( 1 ); let parent_scale = parent_scale_i as f32; // `OverlaySpec::size` carries `Length`s resolved against the main // surface's physical viewport (the app's layout space), so an overlay // sized with `Length::widget( … )` scales with the display exactly like // any other widget. `Length::px( n )` keeps a fixed size. let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 ); let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 ) { ( s.0.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32, s.1.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32, ) }; // `OverlaySpec::size` is physical pixels (the app's layout space); the // layer-shell `set_size` is logical. They only coincide at scale 1, so // convert here — without it a scale-2 overlay requests a surface twice // its intended size. `0 = fill` survives the divide. let to_logical_size = move | ( w, h ): ( u32, u32 ) | -> ( u32, u32 ) { ( ( w as f32 / parent_scale ).round() as u32, ( h as f32 / parent_scale ).round() as u32, ) }; // The exclusive zone measures the same band the surface occupies, so it // lives in the same space and needs the same conversion. `-1` (ignore // other zones) and `0` (reserve nothing) are sentinels, not distances. let to_logical_zone = move | z: i32 | -> i32 { if z > 0 { ( z as f32 / parent_scale ).round() as i32 } else { z } }; // Snapshot the previous-frame anchor lookup table so we can resolve // `anchor_widget_id` → `Rect` without holding a borrow on `data.main` // across the overlay-mut loop below. let main_widget_rects = &data.main.frame.widget_rects; let overlays_m = &mut data.overlays; for spec in &specs { let resolved_size = resolve_size( &spec.size ); if let Some( ss ) = overlays_m.get_mut( &spec.id ) { // Already-live overlay: propagate size changes to the layer // surface so apps can animate an overlay's dimensions (e.g. a // slide-down panel whose height grows each frame). The very // first size was applied at `materialize` time and recorded in // `last_requested_size`, so we only commit when it actually // differs. Commit is needed after `set_size` so the compositor // sends a configure; the usual `on_configure` path picks up the // new dimensions and drives the redraw. Popups don't grow / // shrink mid-life — close and reopen instead. let resolved_zone = to_logical_zone( spec.exclusive_zone ); if resolved_size != ss.last_requested_size || resolved_zone != ss.last_requested_zone { if let SurfaceKind::Layer( ref layer_surface ) = ss.surface { if resolved_size != ss.last_requested_size { let ( lw, lh ) = to_logical_size( resolved_size ); layer_surface.set_size( lw, lh ); ss.last_requested_size = resolved_size; } if resolved_zone != ss.last_requested_zone { layer_surface.set_exclusive_zone( resolved_zone ); ss.last_requested_zone = resolved_zone; } layer_surface.commit(); } } // Compare the anchor at integer logical-pixel resolution: // floating-point jitter would otherwise call `reposition` // every frame, which several compositors react to by // dropping the popup grab. if let SurfaceKind::Popup( ref popup ) = ss.surface { if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt ) { if let Some( anchor_rect ) = main_widget_rects.iter() .find( |w| w.id == Some( anchor_id ) ) .map( |w| w.rect ) { let to_logical = | r: Rect | { ( ( r.x / parent_scale ).round() as i32, ( r.y / parent_scale ).round() as i32, ( r.width / parent_scale ).round().max( 1.0 ) as i32, ( r.height / parent_scale ).round().max( 1.0 ) as i32, ) }; let new_q = to_logical( anchor_rect ); let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true ); if moved { if let Ok( positioner ) = XdgPositioner::new( xdg_shell ) { let ( ax, ay, aw, ah ) = new_q; let ( spec_w, spec_h ) = resolved_size; let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 }; let popup_h = spec_h.max( 1 ) as i32; positioner.set_size( popup_w, popup_h ); positioner.set_anchor_rect( ax, ay, aw, ah ); positioner.set_anchor( PositionerAnchor::Bottom ); positioner.set_gravity( Gravity::Bottom ); positioner.set_constraint_adjustment( ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX, ); ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 ); popup.reposition( &positioner, ss.popup_reposition_token ); ss.last_popup_anchor = Some( anchor_rect ); } } } } } continue; } // `anchor_widget_id = Some(_)` → xdg-popup path; `None` → // wlr-layer-shell path. if let Some( anchor_id ) = spec.anchor_widget_id { let Some( xdg_shell ) = xdg_shell_opt else { eprintln!( "ltk: ignoring popup overlay {:?} — main surface is not an xdg-shell window", spec.id ); continue; }; let Some( ref parent ) = parent_xdg else { eprintln!( "ltk: ignoring popup overlay {:?} — no xdg parent surface", spec.id ); continue; }; let Some( anchor_rect ) = main_widget_rects.iter() .find( |w| w.id == Some( anchor_id ) ) .map( |w| w.rect ) else { eprintln!( "ltk: popup overlay {:?} could not find anchor widget id {:?}", spec.id, anchor_id ); continue; }; let positioner = match XdgPositioner::new( xdg_shell ) { Ok( p ) => p, Err( e ) => { eprintln!( "ltk: XdgPositioner::new failed for popup {:?}: {e}", spec.id ); continue; } }; // Convert the requested popup size and the anchor rect from // physical pixels (the layout coordinate space) to logical // pixels — the positioner expresses everything in window // geometry, which is logical. `size.0 == 0` is the // "match anchor width" convention (paralleling the // layer-shell `0 = fill` semantic): the popup is sized to // the trigger pill so combos / selects render flush with // the field they belong to. let ax = ( anchor_rect.x / parent_scale ).round() as i32; let ay = ( anchor_rect.y / parent_scale ).round() as i32; let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32; let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32; let ( spec_w, spec_h ) = resolved_size; let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 }; let popup_h = spec_h.max( 1 ) as i32; positioner.set_size( popup_w, popup_h ); positioner.set_anchor_rect( ax, ay, aw, ah ); positioner.set_anchor( PositionerAnchor::Bottom ); positioner.set_gravity( Gravity::Bottom ); positioner.set_constraint_adjustment( ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX, ); // xdg_popup.grab must be issued before the first commit // (error 0 `invalid_grab` otherwise). `Popup::new` commits // internally, so the lower-level `from_surface` path is // the only one that lets the grab land in time. let surface = match Surface::new( cs, qh ) { Ok( s ) => s, Err( e ) => { eprintln!( "ltk: Surface::new failed for popup overlay {:?}: {e}", spec.id ); continue; } }; let popup = match Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell ) { Ok( p ) => p, Err( e ) => { eprintln!( "ltk: Popup::from_surface failed for overlay {:?}: {e}", spec.id ); continue; } }; if let Some( ref seat ) = grab_seat { popup.xdg_popup().grab( seat, grab_serial ); } popup.wl_surface().commit(); let mut ss = SurfaceState::::new( SurfaceKind::Popup( popup ), 0.0, String::new() ); ss.last_requested_size = resolved_size; ss.last_popup_anchor = Some( anchor_rect ); overlays_m.insert( spec.id, ss ); continue; } // wlr-layer-shell path. let Some( layer_shell ) = layer_shell_opt else { eprintln!( "ltk: ignoring layer-shell overlay {:?} — wlr-layer-shell not available", spec.id ); continue; }; let cfg = LayerConfig { layer: spec.layer.to_wlr_layer(), exclusive_zone: to_logical_zone( spec.exclusive_zone ), anchor: spec.anchor, size: to_logical_size( resolved_size ), keyboard_exclusive: spec.keyboard_exclusive, namespace: "ltk-overlay", }; let mut surface = SurfaceKind::Pending( cfg ); if let Some( ref output ) = output_opt { surface.materialize( cs, layer_shell, qh, output ); } let mut ss = SurfaceState::::new( surface, 0.0, String::new() ); // Inherit the parent's scale so the first configure allocates a // HiDPI buffer and lays out at the right size from frame one, // instead of rendering at scale 1 until `scale_factor_changed` // lands a frame or two later. ss.scale_factor = parent_scale_i; ss.last_requested_size = resolved_size; ss.last_requested_zone = to_logical_zone( spec.exclusive_zone ); ss.layer_anchor = Some( spec.anchor ); overlays_m.insert( spec.id, ss ); } }