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:
482
src/event_loop/run.rs
Normal file
482
src/event_loop/run.rs
Normal file
@@ -0,0 +1,482 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use smithay_client_toolkit::
|
||||
{
|
||||
compositor::CompositorState,
|
||||
output::OutputState,
|
||||
registry::RegistryState,
|
||||
seat::SeatState,
|
||||
shell::
|
||||
{
|
||||
WaylandSurface,
|
||||
wlr_layer::LayerShell,
|
||||
xdg::
|
||||
{
|
||||
XdgShell,
|
||||
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 crate::app::{ App, InvalidationScope };
|
||||
use crate::draw::draw_frame;
|
||||
use crate::types::Point;
|
||||
|
||||
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
use super::error::RunError;
|
||||
use super::invalidation::apply_invalidation;
|
||||
use super::overlays_reconcile::reconcile_overlays;
|
||||
|
||||
/// 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( () )
|
||||
}
|
||||
Reference in New Issue
Block a user