Files
ltk/src/event_loop/run.rs
T
admin 58b8dd7b90
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Focus requests reach widgets clipped by a scroll viewport, and stop retrying after a few frames
A scroll viewport keeps in `widget_rects` only the widgets at least partly inside its visible area (`draw/layout.rs`, the translation of the sub-canvas rects to surface space). A focus request whose target sits below the fold therefore never finds it: `App::take_focus_request` was honoured only for widgets already laid out on screen. The Wi-Fi page of settings is one such case by construction: tapping a secured network inserts the password form under the row and asks for focus on the field, so for a row in the lower part of the list the field is born outside the viewport and the request misses. Worse, the miss branch parked the id in `focus_retry`, marked the view dirty and requested a redraw on every iteration, so the application rebuilt and repainted at the compositor's frame rate for as long as the widget stayed hidden.
The layout pass now records the widgets it drops for carrying a `WidgetId`: `DrawCtx::scroll_clipped` holds `(id, scroll flat_idx, rect)` with the rect in the scroll's content coordinates, the same frame the navigable-item list uses, and `draw_frame` hands it to `FrameState`. When a focus request misses, `AppData::reveal_clipped_widget` looks the id up in that list on the main surface and the overlays, finds the viewport in `scroll_rects`, and moves its offset on the axes the scroll allows so the rect lands inside; the next frame lays the widget out and the retry focuses it. Nested scrolls resolve one viewport per frame.
The rule that computes the new offset is `scroll::offset_into_view`, extracted from the keyboard navigation in `input/keyboard/nav.rs`, which now calls it instead of carrying its own copy; unit tests cover the three cases.
`focus_retry` carries a budget of drawn frames (`FOCUS_RETRY_FRAMES`, four), decremented only on iterations that actually drew, so a request made while a frame callback is pending does not lose its chance. A fresh request from the application replaces a pending retry with a full budget. When the budget runs out the request is dropped, which ends the redraw loop for a widget that never appears.
Documented in the `take_focus_request` doc comment, the cookbook and the changelog; Debian revision 0.3.0-3.
2026-09-14 11:50:27 +02:00

962 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;
/// Drawn frames a focus request waits for its target to appear: one for
/// the rebuilt view, one per scroll viewport moved to reveal the widget.
const FOCUS_RETRY_FRAMES: u8 = 4;
/// 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 retry = data.focus_retry.take();
let focus_request = data.app.take_focus_request()
.map( |id| ( id, FOCUS_RETRY_FRAMES ) )
.or( retry );
if let Some( ( id, frames_left ) ) = focus_request
{
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 {
let frames_left = if any_drawable { frames_left.saturating_sub( 1 ) } else { frames_left };
if frames_left > 0
{
data.reveal_clipped_widget( id );
data.focus_retry = Some( ( id, frames_left ) );
data.view_dirty = true;
data.main.request_redraw();
}
}
}
}
data.session.on_exit( &data.app );
Ok( () )
}