Files
ltk/src/event_loop/run.rs
Pedro M. de Echanove Pasquin a2752a5bd3
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Handle SIGTERM/SIGINT with a sigaction self-pipe instead of a signalfd, and clear the signal mask of spawned children
The signalfd-based source (calloop's `signals` feature) needs SIGTERM and SIGINT blocked in every thread of the process, since the kernel hands a process-directed signal to any thread that does not block it. calloop only blocks them in the thread that creates the source, so the runtime depended on `ltk::run` being entered before the app spawned any thread — a constraint apps do not honour: crustace-notifier builds its tokio runtime and its D-Bus thread first, and in that configuration a SIGTERM lands on a tokio worker and kills the process outright, skipping the clean exit (`save_state`, `mark_clean_exit`) entirely.
The blocked mask had a second consequence: it is inherited across fork and exec, and Rust's std deliberately does not reset it in the child. Every process an ltk app spawns from a thread created after `ltk::run` therefore started with SIGTERM blocked. For the `gsettings monitor` child of the text-scale watcher this meant it never died on systemd's stop, so the unit's cgroup stayed populated until `TimeoutStopSec` (90 s) expired and systemd resorted to SIGKILL — visible as a stop job holding up every session shutdown.
`install_signal_source` now installs a `sigaction` handler for SIGTERM and SIGINT that writes the signal number to a non-blocking `O_CLOEXEC` pipe (an atomic load and a `write`, errno preserved); the read end is a calloop `Generic` source that drains the pipe and sets `exit_requested`. A handler is process-wide, so the clean exit works no matter which thread receives the signal or when it was created, and no thread blocks anything, so children inherit a clean mask. `SA_RESTART` keeps the handler from injecting spurious EINTRs into the app's own threads; the pipe wakes the event loop regardless.
`text_scale` additionally clears the child's mask in `pre_exec` before running `gsettings`, as a guard against a mask already dirty when the process was launched. The `save_state` docs drop the caveat about threads started before `run`, the ordering comment at the call site goes with the constraint it described, and the `signals` feature is dropped from calloop. `libc` becomes a direct dependency for `sigaction`, `pipe2` and `sigprocmask`.
Tests: `child_starts_with_clear_signal_mask` blocks SIGTERM in the test thread, spawns a child through the helper and checks its `SigBlk` in `/proc`, with the inverse control asserting that std really does inherit the mask (the reason the helper exists); `handler_writes_signal_to_pipe` exercises the handler directly and through a real `sigaction` + `raise`, then restores `SIG_DFL`.
2026-08-25 09:47:08 +02:00

950 lines
34 KiB
Rust

// 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,
subcompositor::SubcompositorState,
};
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::types::Point;
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use super::error::RunError;
use super::frame::draw_frame;
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>( mut 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:?}" ) ) )?;
super::session::install_signal_source( &event_loop.handle() )?;
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:?}" ) } )?;
// Optional: compositors lacking wl_subcompositor leave this None and
// App::subsurfaces() silently degrades to no subsurfaces.
let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok();
// 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 session_enabled = force_window.is_some() || matches!( app.shell_mode(), crate::app::ShellMode::Window );
let mut session = if session_enabled
{
super::session::SessionRuntime::bootstrap( &mut app )
} else {
super::session::SessionRuntime::disabled()
};
if session_enabled
{
session.bind( &globals, &qh );
}
let app_id = app.app_id().to_string();
if let Some( ( _, ref cfg_id ) ) = force_window
{
if cfg_id != &app_id
{
eprintln!( "ltk: window_config app_id {cfg_id:?} differs from App::app_id {app_id:?}; App::app_id wins" );
}
}
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 );
}
};
// `restore_toplevel` must precede the first commit, so the session
// attach sits right before `commit()`.
let make_window = |xdg: &XdgShell, title: &str, session: &mut super::session::SessionRuntime, attach: bool|
{
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title );
window.set_app_id( app_id.as_str() );
apply_size_hint( &window );
apply_fullscreen( &window );
if attach
{
session.attach_toplevel( &window, &qh );
}
window.commit();
window
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, _ ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let window = make_window( &xdg, title.as_str(), &mut session, true );
( SurfaceKind::Window( window ), Some( xdg ) )
} else {
// Use shell_mode() to determine surface type
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::SessionLock => {
// Lock surface is created in SessionLockHandler::locked once the
// compositor grants the lock; until then a placeholder.
( SurfaceKind::PendingLock, None )
}
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let window = make_window( &xdg, "ltk", &mut session, true );
( 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 window = make_window( &xdg, "ltk", &mut session, false );
( SurfaceKind::Window( window ), Some( xdg ) )
}
}
}
};
// Layer-shell apps still get xdg_wm_base when the compositor has it, so
// a `Layer::Window` overlay can be created later.
let xdg_shell = xdg_shell.or_else( || XdgShell::bind( &globals, &qh ).ok() );
// Bind the session-lock manager and request the lock. We don't keep the
// `SessionLockState` (the manager) around: it has no `Drop`, so the
// manager object persists in the connection, and the lock lifecycle runs
// entirely off the returned `SessionLock` + its surfaces.
let session_lock =
if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock )
{
smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh )
.lock( &qh )
.ok()
} else {
None
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
// wl_data_device_manager: optional. Required for cross-application
// copy/paste; absence means the clipboard stays process-local.
let data_device_manager =
smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok();
let ( clipboard_inbox_tx, clipboard_inbox_rx ) = super::data_device::clipboard_inbox();
let ( drop_inbox_tx, drop_inbox_rx ) = super::data_device::drop_inbox();
// AT-SPI2 / accessibility. `try_new` returns `None` when the
// platform adapter cannot be created (no daemon on the bus,
// headless CI, etc.) — the runtime then runs with no
// accessibility tree, which is the previous behaviour.
let a11y = crate::a11y::A11yState::try_new( &app_id, &app_id );
// xdg-activation-v1: optional. Compositors that don't carry the
// global leave `activation_state` as `None` and the inbound /
// outbound activation paths silently degrade to no-ops.
let activation_state =
smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok();
// `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an
// incoming activation token (the launcher that spawned us set it
// in the new process's environment). Honoured exactly once, after
// the main surface receives its first configure — that is the
// earliest moment we can call `xdg_activation_v1.activate`.
let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() );
// Avoid the token leaking into any child process the app spawns —
// it is single-use and would be invalid the second time anyway.
if activation_token_pending.is_some()
{
// SAFETY: removing an env var is sound only when no other thread
// is reading the environment concurrently. We are still in the
// init phase before `set_channel_sender`, so the app has had
// no opportunity to spawn worker threads yet.
unsafe { std::env::remove_var( "XDG_ACTIVATION_TOKEN" ); }
}
// `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() && app.window_resizable();
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,
subcompositor,
shm,
session_lock,
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,
text_input_secure: false,
activation_state,
activation_token_pending,
session,
data_device_manager,
data_device: None,
clipboard_source: None,
clipboard_inbox_tx,
clipboard_inbox_rx,
drop_position: None,
drop_mime: None,
drop_inbox_tx,
drop_inbox_rx,
a11y,
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(),
secondaries: Vec::new(),
main_output: None,
subsurfaces: std::collections::HashMap::new(),
subsurface_gles_canvas: None,
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,
first_frame_committed: false,
focus_retry: None,
perf: super::perf::PerfState::new(),
};
// 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 );
}
// Follow the desktop's accessibility text scale: a watcher thread reads
// `org.gnome.desktop.interface text-scaling-factor` and streams changes
// here; applying the factor and repainting is all it takes because font
// resolution multiplies by `text_scale()` at paint time.
{
let ( tx, channel ) = calloop::channel::channel::<f32>();
event_loop.handle()
.insert_source(
channel,
|event, _, data: &mut AppData<A>|
{
if let calloop::channel::Event::Msg( factor ) = event
{
if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; }
crate::types::set_text_scale( factor );
data.dirty_caches();
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
}
},
)
.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e:?}" ) ) )?;
super::text_scale::spawn_watcher( tx );
}
// 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()
{
data.perf.warn_poll_interval( dur );
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:?}" ) ) )?;
}
if data.session.store.is_some()
{
super::session::install_save_timer( &event_loop.handle() )?;
}
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( calloop::Error::OtherError( ref e ) ) =>
{
// wayland-client surfaces the closed-socket condition as an
// OtherError wrapping a WaylandError::Io(BrokenPipe) — one
// level deeper than a direct io::Error. Walk the source()
// chain so we catch it regardless of how many wrapper types
// sit between calloop and the raw io::Error.
let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() );
let mut is_closed = false;
while let Some( err ) = src
{
if let Some( io ) = err.downcast_ref::<std::io::Error>()
{
if matches!( io.kind(),
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset )
{
is_closed = true;
break;
}
}
src = err.source();
}
if is_closed
{
eprintln!( "ltk: wayland connection lost; exiting" );
data.exit_requested = true;
continue;
}
panic!( "dispatch: {e:?}" );
}
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 );
// Issue any xdg-activation tokens the app asked for. The tag rides
// in `RequestData::app_id` and comes back through
// `ActivationHandler::new_token` (handlers.rs) as the token. When the
// compositor never advertised the global, answer with an empty token
// so the caller still proceeds (falling back to its own matching).
for tag in data.app.take_activation_requests()
{
match data.activation_state
{
Some( ref activation ) =>
{
let req = smithay_client_toolkit::activation::RequestData {
app_id: Some( tag ),
seat_and_serial: None,
surface: data.main.surface.try_wl_surface().cloned(),
};
activation.request_token::<AppData<A>>( &data.qh, req );
}
None =>
{
if let Some( msg ) = data.app.on_activation_token( tag, String::new() )
{
data.pending_msgs.push( msg );
}
}
}
}
// Drain any inbound clipboard payloads delivered by the
// data-device worker thread. Latest writer wins; the channel
// is unbounded but selection payloads are small (capped at
// 16 MiB inside the worker, see `data_device::DataDeviceHandler::selection`).
while let Ok( text ) = data.clipboard_inbox_rx.try_recv()
{
data.clipboard = text;
}
while let Ok( p ) = data.drop_inbox_rx.try_recv()
{
data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text );
}
// 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 );
}
// App asked to exit (e.g. the lockscreen authenticated). For a
// session-lock surface, unlock first — exiting without unlocking
// leaves the compositor's lock in place by design.
if data.app.requested_exit()
{
if let Some( lock ) = data.session_lock.take()
{
lock.unlock();
let _ = conn.roundtrip();
}
data.exit_requested = true;
}
// 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 )
|| data.secondaries.iter().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;
}
let first_commit_now = draw_frame( &mut data );
if first_commit_now
{
data.app.on_first_frame_committed();
}
// Push the freshly-laid-out widget tree to AccessKit. The
// closure only runs when an AT client is actually
// listening (the adapter no-ops `update_if_active`
// otherwise), so the build cost is paid on demand. Main
// surface only — overlays would require per-surface
// adapters, which the AT-SPI2 model is not built for.
let mut a11y_taken = data.a11y.take();
if let Some( ref mut a ) = a11y_taken
{
let main_w = data.main.width as f32;
let main_h = data.main.height as f32;
let mut overlay_meta: Vec<( u8, crate::app::OverlayId, f32, f32 )> = Vec::new();
let mut next_id: u8 = 1;
for ( id, ss ) in data.overlays.iter()
{
if next_id == 0 { break; }
let ( ox, oy ) = data.surface_offset_for( super::SurfaceFocus::Overlay( *id ) );
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; }
overlay_meta.push( ( next_id, *id, ox, oy ) );
next_id = next_id.saturating_add( 1 );
}
let kb_focus_id = match data.keyboard_focus
{
super::SurfaceFocus::Main => 0u8,
super::SurfaceFocus::Overlay( id ) =>
overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ),
};
let mut surfaces: Vec<crate::a11y::tree::SurfaceView<A::Message>> = Vec::new();
surfaces.push( crate::a11y::tree::SurfaceView
{
focus_id: 0,
is_main: true,
label: None,
widget_rects: &data.main.frame.widget_rects,
extras: &data.main.frame.accessible_extras,
focused_idx: data.main.focused_idx,
pressed_idx: data.main.gesture.pressed_idx,
pending_text_values: &data.main.pending_text_values,
cursor_state: &data.main.frame.cursor_state,
width: main_w,
height: main_h,
offset_x: 0.0,
offset_y: 0.0,
} );
for ( fid, id, ox, oy ) in &overlay_meta
{
let Some( ss ) = data.overlays.get( id ) else { continue };
surfaces.push( crate::a11y::tree::SurfaceView
{
focus_id: *fid,
is_main: false,
label: None,
widget_rects: &ss.frame.widget_rects,
extras: &ss.frame.accessible_extras,
focused_idx: ss.focused_idx,
pressed_idx: ss.gesture.pressed_idx,
pending_text_values: &ss.pending_text_values,
cursor_state: &ss.frame.cursor_state,
width: ss.width as f32,
height: ss.height as f32,
offset_x: *ox,
offset_y: *oy,
} );
}
let app_name = data.app.app_id();
a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) );
}
data.a11y = a11y_taken;
}
// Reconcile + reposition input-transparent subsurfaces every
// iteration, decoupled from the main redraw cadence: a finger drag
// emits motion events faster than frame callbacks clear
// `frame_pending`, so gating this on a main draw would pin the
// subsurface between frames. A position-only change is just
// set_position + a bare parent commit; no-ops when nothing moved.
super::subsurface::reconcile_subsurfaces( &mut data );
// Drain inbound AT-SPI2 actions (Orca pressing a button,
// switch-control focusing a node, etc.) and translate each
// into a synthetic press / focus on the matching widget.
// Actions for widgets that disappeared between dispatch and
// drain are silently dropped — the action loop is best-effort.
let a11y_requests: Vec<accesskit::ActionRequest> =
if let Some( ref mut a ) = data.a11y { a.action_rx.try_iter().collect() } else { Vec::new() };
if !a11y_requests.is_empty()
{
let qh = data.qh.clone();
let overlay_keys: Vec<crate::app::OverlayId> = data.overlays.keys().copied().collect();
for req in a11y_requests
{
let Some( r ) = crate::a11y::tree::parse_id( req.target ) else { continue };
if r.kind != 0 { continue; }
let idx = r.idx as usize;
let focus_target = if r.focus == 0
{
SurfaceFocus::Main
}
else
{
let oi = ( r.focus as usize ).saturating_sub( 1 );
let Some( id ) = overlay_keys.get( oi ).copied() else { continue };
SurfaceFocus::Overlay( id )
};
let widget = match focus_target
{
SurfaceFocus::Main => data.main.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ),
};
match req.action
{
accesskit::Action::Click =>
{
if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() )
{
data.pending_msgs.push( msg );
}
}
accesskit::Action::Focus =>
{
data.set_focus( focus_target, Some( idx ), &qh );
}
accesskit::Action::SetValue =>
{
if let Some( w ) = widget
{
match ( &w.handlers, req.data )
{
( crate::widget::WidgetHandlers::Slider { .. }, Some( accesskit::ActionData::NumericValue( v ) ) ) =>
{
if let Some( msg ) = w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 )
{
data.pending_msgs.push( msg );
}
}
( crate::widget::WidgetHandlers::TextEdit { .. }, Some( accesskit::ActionData::Value( s ) ) ) =>
{
if let Some( msg ) = w.handlers.text_change_msg( &s )
{
data.pending_msgs.push( msg );
}
}
_ => {}
}
}
}
accesskit::Action::Increment | accesskit::Action::Decrement =>
{
if let Some( w ) = widget
{
if let crate::widget::WidgetHandlers::Slider { value, .. } = &w.handlers
{
let step = 0.05f32;
let delta = if matches!( req.action, accesskit::Action::Increment ) { step } else { -step };
let v = ( *value + delta ).clamp( 0.0, 1.0 );
if let Some( msg ) = w.handlers.slider_change_msg( v )
{
data.pending_msgs.push( msg );
}
}
}
}
accesskit::Action::ScrollUp | accesskit::Action::ScrollDown
| accesskit::Action::ScrollLeft | accesskit::Action::ScrollRight =>
{
const STEP: f32 = 80.0;
let ( dx, dy ) = match req.action
{
accesskit::Action::ScrollLeft => ( -STEP, 0.0 ),
accesskit::Action::ScrollRight => ( STEP, 0.0 ),
accesskit::Action::ScrollUp => ( 0.0, -STEP ),
accesskit::Action::ScrollDown => ( 0.0, STEP ),
_ => ( 0.0, 0.0 ),
};
let ss = match focus_target
{
SurfaceFocus::Main => Some( &mut data.main ),
SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
};
if let Some( ss ) = ss
{
if let Some( ( _, _, _ ) ) = ss.frame.scroll_rects.last().copied()
{
let scroll_idx = ss.frame.scroll_rects.last().unwrap().1;
let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
entry.0 = ( entry.0 + dx ).max( 0.0 );
entry.1 = ( entry.1 + dy ).max( 0.0 );
ss.request_redraw();
}
}
}
accesskit::Action::ScrollIntoView =>
{
let Some( w ) = widget else { continue };
let ss = match focus_target
{
SurfaceFocus::Main => Some( &mut data.main ),
SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
};
if let Some( ss ) = ss
{
let target = w.rect;
let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 };
let container = ss.frame.scroll_rects.iter().rev()
.find( |( r, _, _ )| r.contains( probe ) )
.copied();
if let Some( ( r, idx, _ ) ) = container
{
let entry = ss.frame.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
if target.y < r.y { entry.1 -= r.y - target.y; }
if target.y + target.height > r.y + r.height
{
entry.1 += ( target.y + target.height ) - ( r.y + r.height );
}
if target.x < r.x { entry.0 -= r.x - target.x; }
if target.x + target.width > r.x + r.width
{
entry.0 += ( target.x + target.width ) - ( r.x + r.width );
}
entry.0 = entry.0.max( 0.0 );
entry.1 = entry.1.max( 0.0 );
ss.request_redraw();
}
}
}
_ => {}
}
}
}
// 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, …).
let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() );
if let Some( id ) = focus_id
{
let mut hit: Option<( SurfaceFocus, usize )> = data.main.frame.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.frame.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();
}
}
}
} else {
data.focus_retry = Some( id );
data.view_dirty = true;
data.main.request_redraw();
}
}
}
data.session.on_exit( &data.app );
Ok( () )
}