refactor: split every monolithic module into focused submodules
Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
@@ -2,878 +2,24 @@
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
pub( crate ) mod app_data;
|
||||
pub( crate ) mod clipboard;
|
||||
pub( crate ) mod context_menu;
|
||||
pub( crate ) mod cursor_shape;
|
||||
pub( crate ) mod drag;
|
||||
pub( crate ) mod focus;
|
||||
mod handlers;
|
||||
pub( crate ) mod repeat;
|
||||
pub( crate ) mod surface;
|
||||
pub( crate ) mod text_editing;
|
||||
pub( crate ) mod tooltip;
|
||||
|
||||
pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
pub( crate ) mod error;
|
||||
pub( crate ) mod run;
|
||||
pub( crate ) mod invalidation;
|
||||
pub( crate ) mod overlays_reconcile;
|
||||
|
||||
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, the compositor disconnecting
|
||||
/// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any
|
||||
/// other runtime error during the dispatch loop still panics, since the
|
||||
/// surface is already on screen and the state machine cannot be unwound
|
||||
/// from this entry point.
|
||||
///
|
||||
/// 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:?}" ) } )
|
||||
};
|
||||
|
||||
// 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 ) ) =>
|
||||
{
|
||||
window.set_min_size( Some( ( w, h ) ) );
|
||||
window.set_max_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();
|
||||
|
||||
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
|
||||
// bind + dispatch routing; absence of the global is fine, the inner
|
||||
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
|
||||
// fires. The list is built unconditionally so apps that override the
|
||||
// callback always see a consistent stream when the compositor does carry
|
||||
// the protocol.
|
||||
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
|
||||
|
||||
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 pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some();
|
||||
|
||||
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,
|
||||
foreign_toplevel_list,
|
||||
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(),
|
||||
tooltip_pending: None,
|
||||
tooltip_visible: None,
|
||||
qh: qh.clone(),
|
||||
last_pointer_serial: 0,
|
||||
last_input_serial: 0,
|
||||
exit_requested: false,
|
||||
pending_fullscreen,
|
||||
pending_size_hint_unpin,
|
||||
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 = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
|
||||
{
|
||||
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
|
||||
( a, b ) => a.or( b ),
|
||||
};
|
||||
match event_loop.dispatch( timeout, &mut data )
|
||||
{
|
||||
Ok( () ) => {},
|
||||
Err( calloop::Error::IoError( ref e ) )
|
||||
if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
|
||||
{
|
||||
// On a fatal `wl_display.error` the compositor closes the
|
||||
// socket; the backend has the typed error stashed but the
|
||||
// dispatch result only carries the resulting `BrokenPipe`.
|
||||
if let Some( pe ) = conn.protocol_error()
|
||||
{
|
||||
eprintln!(
|
||||
"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
|
||||
pe.object_interface, pe.object_id, pe.code, pe.message
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
eprintln!( "ltk: wayland connection lost ({e}); exiting" );
|
||||
}
|
||||
data.exit_requested = true;
|
||||
continue;
|
||||
}
|
||||
Err( e ) => panic!( "dispatch: {e:?}" ),
|
||||
}
|
||||
// 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();
|
||||
data.check_tooltip_deadline();
|
||||
|
||||
// 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
|
||||
{
|
||||
let mut specs = data.app.overlays();
|
||||
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
|
||||
data.cached_overlays = Some( specs );
|
||||
data.overlays_dirty = false;
|
||||
}
|
||||
draw_frame( &mut data );
|
||||
}
|
||||
|
||||
// Focus the widget with the requested WidgetId if the app requests it.
|
||||
// Walks main + every overlay because the targeted widget can live
|
||||
// on any of them (a search field on a launcher overlay, a text
|
||||
// edit on a dialog modal, …).
|
||||
if let Some( id ) = data.app.take_focus_request()
|
||||
{
|
||||
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
|
||||
.find( |w| w.id == Some( id ) )
|
||||
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
|
||||
if hit.is_none()
|
||||
{
|
||||
for ( ov_id, surf ) in &data.overlays
|
||||
{
|
||||
if let Some( w ) = surf.widget_rects.iter().find( |w| w.id == Some( id ) )
|
||||
{
|
||||
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some( ( focus, flat_idx ) ) = hit
|
||||
{
|
||||
let qh = data.qh.clone();
|
||||
data.set_focus( focus, Some( flat_idx ), &qh );
|
||||
match focus
|
||||
{
|
||||
SurfaceFocus::Main => data.main.request_redraw(),
|
||||
SurfaceFocus::Overlay( id ) =>
|
||||
{
|
||||
if let Some( s ) = data.overlays.get_mut( &id )
|
||||
{
|
||||
s.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 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<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;
|
||||
ss.layer_anchor = Some( spec.anchor );
|
||||
overlays_m.insert( spec.id, ss );
|
||||
}
|
||||
}
|
||||
pub( crate ) use app_data::AppData;
|
||||
pub( crate ) use surface::{ LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
pub use error::RunError;
|
||||
pub( crate ) use run::{ run, try_run };
|
||||
pub use invalidation::diff_overlay_ids;
|
||||
|
||||
Reference in New Issue
Block a user