Files
ltk/src/event_loop/run.rs
Pedro M. de Echanove Pasquin df8fcbf757
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
app: client-side xdg-activation token requests
Adds an outbound path so an app can obtain an `xdg-activation-v1` token from the compositor and hand it to a child it is about to launch (the `$XDG_ACTIVATION_TOKEN` convention). Until now ltk only honoured inbound activation (self-activating the main surface from an inherited token); requesting a token for another app was out of scope.
Two new `App` trait methods, both defaulting to no-op: `take_activation_requests` returns the tags the app wants tokens for this iteration, and `on_activation_token` delivers the issued token paired with its tag. The run loop drains the requests right after `poll_external` and calls `ActivationState::request_token`, carrying the tag in `RequestData::app_id`; `ActivationHandler::new_token` reads the tag back out and routes the token to the app through `on_activation_token`. When the compositor never advertised the activation global, each request is answered immediately with an empty token so the caller still proceeds and can fall back to its own matching instead of stalling.
2026-06-09 23:53:31 +02:00

884 lines
32 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>( 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:?}" ) } )?;
// 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 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::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 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 ) )
}
}
}
};
// 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_app_name = "ltk-app";
let a11y_app_id = "net.liberux.ltk";
let a11y = crate::a11y::A11yState::try_new( a11y_app_name, a11y_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();
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,
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(),
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,
};
// 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( 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 );
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.widget_rects,
extras: &data.main.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.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.widget_rects,
extras: &ss.accessible_extras,
focused_idx: ss.focused_idx,
pressed_idx: ss.gesture.pressed_idx,
pending_text_values: &ss.pending_text_values,
cursor_state: &ss.cursor_state,
width: ss.width as f32,
height: ss.height as f32,
offset_x: *ox,
offset_y: *oy,
} );
}
let app_name = "ltk-app";
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.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.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.scroll_rects.last().copied()
{
let scroll_idx = ss.scroll_rects.last().unwrap().1;
let entry = ss.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.scroll_rects.iter().rev()
.find( |( r, _, _ )| r.contains( probe ) )
.copied();
if let Some( ( r, idx, _ ) ) = container
{
let entry = ss.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.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();
}
}
}
} else {
data.focus_retry = Some( id );
data.view_dirty = true;
data.main.request_redraw();
}
}
}
Ok( () )
}