First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

814
src/event_loop/mod.rs Normal file
View File

@@ -0,0 +1,814 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod app_data;
mod handlers;
pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use smithay_client_toolkit::
{
compositor::{ CompositorState, Surface },
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::
{
WaylandSurface,
wlr_layer::LayerShell,
xdg::
{
XdgPositioner, XdgShell, XdgSurface,
popup::Popup,
window::WindowDecorations,
},
},
shm::Shm,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use wayland_protocols::xdg::shell::client::xdg_positioner::
{
Anchor as PositionerAnchor,
ConstraintAdjustment,
Gravity,
};
use crate::app::{ App, InvalidationScope, SurfaceTarget };
use crate::draw::draw_frame;
use crate::types::{ Point, Rect };
use std::collections::HashSet;
/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail
/// to bring up the event loop.
///
/// Every variant maps to a fatal failure during init: the Wayland
/// connection, the calloop event loop, or one of the protocol bindings
/// the runtime cannot operate without (`wl_compositor`, `wl_shm`,
/// `xdg_wm_base`). Once init succeeds, runtime errors during the dispatch
/// loop still panic — they are non-recoverable from the caller's point
/// of view since the surface is already on screen.
///
/// Embedders that want a software-rendered fallback or that need to
/// degrade gracefully should call [`crate::try_run`] and match on the
/// variants instead of letting [`crate::run`] panic.
#[ derive( Debug ) ]
pub enum RunError
{
/// `WAYLAND_DISPLAY` is unset, the socket is missing, or the
/// compositor refused the handshake. Includes the detailed reason
/// from the underlying `wayland-client` error.
NoWaylandConnection( String ),
/// The Wayland registry could not be enumerated. Almost always a
/// compositor / driver bug — the registry is the first thing every
/// Wayland client touches.
RegistryInit( String ),
/// `calloop`'s `EventLoop::try_new` or its Wayland source insertion
/// failed (typically an `io` error talking to the kernel).
EventLoop( String ),
/// A required Wayland protocol is missing from the compositor.
/// `name` is the wire-format protocol name; `detail` is the
/// underlying bind error.
MissingProtocol
{
name: &'static str,
detail: String,
},
}
impl std::fmt::Display for RunError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
Self::NoWaylandConnection( d ) =>
write!( f, "Wayland connection failed: {d}" ),
Self::RegistryInit( d ) =>
write!( f, "Wayland registry init failed: {d}" ),
Self::EventLoop( d ) =>
write!( f, "event-loop setup failed: {d}" ),
Self::MissingProtocol { name, detail } =>
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
}
}
}
impl std::error::Error for RunError {}
/// Run the application, panicking on init failure. Thin wrapper over
/// [`try_run`] kept for backwards-compatibility — embedders that need
/// to recover from a missing compositor or a stripped-down driver
/// should call [`try_run`] instead.
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
/// Run the application, returning a typed error on init failure.
/// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
let conn = Connection::connect_to_env()
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
let ( globals, queue ) =
smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
let qh = queue.handle();
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
WaylandSource::new( conn.clone(), queue )
.insert( event_loop.handle() )
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
let compositor = CompositorState::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
let shm = Shm::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
// Try to bring EGL up. On failure (no libEGL, no compatible config,
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
// reason and every surface falls back to the SHM path.
let egl_context = match crate::egl_context::EglContext::new( &conn )
{
Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ),
Err( reason ) =>
{
crate::egl_context::log_software_fallback( &reason );
None
}
};
crate::render::set_software_render( egl_context.is_none() );
// Bind layer-shell up front. Both the main surface (when requested via
// ShellMode::Layer) and every overlay returned by App::overlays() share
// this single binding.
let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();
// Backwards compatibility: window_config() overrides shell_mode()
let force_window = app.window_config()
.map( |( t, id )| ( t.to_string(), id.to_string() ) );
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
-> Result<XdgShell, RunError>
{
XdgShell::bind( globals, qh )
.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
};
// Pinning min == max is the standard idiom on xdg-shell for "I
// want this exact size". Skipped when going fullscreen — Mutter
// rejects the fullscreen request if a min_size constrains it.
let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen() { return; }
match app.window_size_hint()
{
Some( ( w, h ) ) =>
{
// `set_min_size` doubles as the suggested initial size:
// most compositors send the first configure with this
// value, after which the surface remains user-resizable
// (the runtime adopts whatever size the compositor
// supplies on subsequent configures). `set_max_size` is
// deliberately *not* called — pinning min == max would
// lock the toplevel and refuse user-initiated resize.
window.set_min_size( Some( ( w, h ) ) );
}
None =>
{
window.set_min_size( Some( ( 360, 480 ) ) );
}
}
};
let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen()
{
window.set_fullscreen( None );
}
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title.as_str() );
window.set_app_id( app_id.as_str() );
apply_size_hint( &window );
apply_fullscreen( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
} else {
// Use shell_mode() to determine surface type
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
ShellMode::Layer( layer ) => {
if layer_shell_opt.is_some()
{
// Defer surface creation until new_output fires: if we create the layer
// surface before the compositor has any output ready (e.g. sway startup),
// the compositor cannot assign it and logs an error.
let cfg = LayerConfig {
layer: layer.to_wlr_layer(),
exclusive_zone: app.exclusive_zone(),
anchor: app.layer_anchor(),
size: app.layer_size(),
keyboard_exclusive: app.keyboard_exclusive(),
namespace: "ltk-sctk",
};
( SurfaceKind::Pending( cfg ), None )
} else {
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
}
}
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default();
let pending_fullscreen = app.start_fullscreen();
let mut data = AppData
{
app,
registry_state: RegistryState::new( &globals ),
seat_state: SeatState::new( &globals, &qh ),
output_state: OutputState::new( &globals, &qh ),
compositor_state: compositor,
shm,
egl_context,
xdg_shell,
layer_shell: layer_shell_opt,
keyboard: None,
pointer: None,
touch: None,
pointer_pos: Point::default(),
cursor_shape_manager:
smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
cursor_shape_device: None,
last_pointer_enter_serial: 0,
current_cursor_shape: None,
text_input_manager,
text_input: None,
shift_pressed: false,
ctrl_pressed: false,
loop_handle: event_loop.handle(),
compositor_repeat_rate: 0,
compositor_repeat_delay: 0,
key_repeat: None,
button_repeat: None,
clipboard: String::new(),
last_press_time: None,
last_press_pos: None,
debug_layout,
pending_msgs: Vec::new(),
pending_drag_inits: Vec::new(),
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(),
cached_view: None,
cached_overlays: None,
view_dirty: true,
overlays_dirty: true,
};
// Register a calloop channel so the app can send messages from any thread.
// Messages sent through the Sender wake the event loop immediately.
{
let ( sender, channel ) = calloop::channel::channel::<A::Message>();
event_loop.handle()
.insert_source(
channel,
|event, _, data: &mut AppData<A>|
{
if let calloop::channel::Event::Msg( msg ) = event
{
// Just queue the message — the run loop will run
// `App::invalidate_after` and apply the resulting
// scope, which is what decides which surfaces (if
// any) actually need to redraw.
data.pending_msgs.push( msg );
}
},
)
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
data.app.set_channel_sender( sender );
}
// Register a periodic timer if the app wants one (e.g. clock tick every second).
// The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval()
{
event_loop.handle()
.insert_source(
Timer::from_duration( dur ),
|_, _, data: &mut AppData<A>|
{
let msgs = data.app.poll_external();
data.pending_msgs.extend( msgs );
let next = data.app.poll_interval()
.unwrap_or( std::time::Duration::from_secs( 1 ) );
TimeoutAction::ToDuration( next )
},
)
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
}
while !data.exit_requested
{
// Sleep until something interesting fires:
// * Wayland event (input, configure, frame callback, …)
// * calloop timer (poll_external)
// * calloop channel (App::set_channel_sender)
// Pacing is driven by `wl_surface.frame` callbacks, so an idle /
// off-screen / VRR display blocks indefinitely instead of polling
// 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();
event_loop.dispatch( timeout, &mut data ).expect( "dispatch" );
// Any surface whose press has now crossed `long_press_duration`
// emits its stored message and flips into drag mode for the rest
// of the gesture.
data.check_long_press_deadlines();
// Poll external messages (immediate async results, e.g. PAM auth channel)
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Process pending messages, folding their per-message invalidation
// scopes into a single decision before applying it.
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
let had_msgs = !msgs.is_empty();
if had_msgs
{
let mut scope = InvalidationScope::Only( Vec::new() );
for msg in msgs
{
scope = scope.union( data.app.invalidate_after( &msg ) );
data.app.update( msg );
}
apply_invalidation( &mut data, scope );
// `update()` may have flipped the busy / loading flag
// the app reads from inside `cursor_override`. Re-sync
// the pointer cursor so the change propagates without
// waiting for the next motion event.
let pf = data.pointer_focus;
data.dispatch_cursor_shape( pf );
}
// Seed `on_drag_move` with the long-press origin for any drag that
// just started. Must run *after* `update()` so the app's drag state
// has already been set up by the paired long-press message — the
// coords would otherwise hit a dragging_app=None shell and be lost.
let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect();
if !drag_inits.is_empty()
{
for origin in drag_inits
{
data.app.on_drag_move( origin.x, origin.y );
}
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
}
// After update() the app state is the source of truth — discard any
// pending text values so that the next keystroke reads the fresh state
// instead of a stale pre-update buffer (e.g. password cleared on auth failure).
if !data.main.pending_text_values.is_empty()
{
data.main.pending_text_values.clear();
}
for ss in data.overlays.values_mut()
{
ss.pending_text_values.clear();
}
// Reconcile the overlay set against the app's current specs: drop any
// overlays whose id disappeared, create new ones for ids that just
// appeared. Specs are re-queried next frame for drawing.
reconcile_overlays( &mut data );
// Draw any surface that's configured, dirty, and not already waiting
// on a frame callback. The compositor decides our cadence — when no
// surface qualifies we just loop back to `dispatch(None)` and sleep.
let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable
{
// Rebuild while motion is in progress and on the first frame
// after it ends, so the settle frame paints at full quality
// instead of freezing one step short of the target.
// `wants_low_quality_paint` is the source of truth so
// finger-tracked drags get the same treatment. Slider /
// scroll drags are owned by the runtime gesture machine,
// not by `App::update`, so OR with the gesture state to
// pick up that motion signal too.
let runtime_slider_motion =
data.main.gesture.dragging_slider.is_some()
|| data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
if runtime_slider_motion
{
data.view_dirty = true;
data.overlays_dirty = true;
}
if data.view_dirty
{
data.cached_view = Some( data.app.view() );
data.view_dirty = false;
}
if data.overlays_dirty
{
data.cached_overlays = Some( data.app.overlays() );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it
if let Some( id ) = data.app.take_focus_request()
{
let found = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| w.flat_idx );
if let Some( flat_idx ) = found
{
let qh = data.qh.clone();
data.set_focus( SurfaceFocus::Main, Some( flat_idx ), &qh );
data.main.request_redraw();
}
}
}
Ok( () )
}
/// Apply a folded [`InvalidationScope`] from one message-batch iteration:
/// dirty the relevant cache(s) so the next draw rebuilds the view via
/// [`App::view`] / [`App::overlays`], and call `request_redraw` on each
/// affected surface so the run loop's "anything to draw" check picks it up.
///
/// `InvalidationScope::All` (the default returned by `App::invalidate_after`)
/// matches the pre-hook behaviour of broadcasting to every surface.
fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: InvalidationScope )
{
match scope
{
InvalidationScope::All =>
{
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
}
InvalidationScope::Only( targets ) =>
{
for t in targets
{
match t
{
SurfaceTarget::Main =>
{
data.view_dirty = true;
data.main.request_redraw();
}
SurfaceTarget::Overlay( id ) =>
{
// `overlays()` returns a single Vec, so any
// per-overlay change forces the whole list to be
// re-queried. Coarser than per-overlay caching but
// matches the existing API shape.
data.overlays_dirty = true;
if let Some( ss ) = data.overlays.get_mut( &id )
{
ss.request_redraw();
}
}
}
}
}
}
}
/// Pure overlay-id diff. Given the current set of live overlay ids and the
/// list the app just returned from [`crate::app::App::overlays`], compute
/// `( added, removed )`.
///
/// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller).
pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ],
) -> ( Vec<crate::app::OverlayId>, Vec<crate::app::OverlayId> )
{
let current_set: HashSet<crate::app::OverlayId> = current.into_iter().collect();
let next_set: HashSet<crate::app::OverlayId> = next.iter().copied().collect();
let added: Vec<_> = next.iter().copied().filter( |id| !current_set.contains( id ) ).collect();
let removed: Vec<_> = current_set.into_iter().filter( |id| !next_set.contains( id ) ).collect();
( added, removed )
}
/// 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.
fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
let specs = data.app.overlays();
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
let wanted: HashSet<crate::app::OverlayId> = 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;
}
}
}
// 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 = data.main.scale_factor.max( 1 ) as f32;
// 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.widget_rects;
let overlays_m = &mut data.overlays;
for spec in &specs
{
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.
if spec.size != ss.last_requested_size
{
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{
layer_surface.set_size( spec.size.0, spec.size.1 );
layer_surface.commit();
ss.last_requested_size = spec.size;
}
}
// 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 ) = spec.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 ) = spec.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::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
ss.last_requested_size = spec.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: spec.exclusive_zone,
anchor: spec.anchor,
size: spec.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::<A::Message>::new( surface, 0.0, String::new() );
ss.last_requested_size = spec.size;
overlays_m.insert( spec.id, ss );
}
}