event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
Five orthogonal capabilities land together because they share the same `try_run` plumbing: an optional global is bound at startup, a piece of state is added to `AppData`, the run-loop iteration drains an inbox / pushes a frame snapshot, and the public surface gains a small set of opt-in `App` hooks. Nothing here breaks an existing app — every new path degrades to a no-op when the compositor does not advertise the relevant global or when the platform adapter cannot start. AT-SPI2 accessibility via AccessKit. A new `src/a11y/` module owns the platform adapter and the inbound `ActionRequest` channel. `A11yState::try_new` constructs an `accesskit_unix::Adapter`; when the AT-SPI2 daemon is not on the session bus (headless CI, locked-down compositors) the constructor returns `None` and the rest of the pipeline runs unchanged. After every successful `draw_frame`, the run loop builds a fresh `accesskit::TreeUpdate` from `widget_rects` and pushes it through the adapter — main surface plus every visible overlay, each translated to global coordinates via `surface_offset_for` so screen readers report positions in the same frame the user sees. Buttons / toggles / checkboxes / radios / list items / sliders / text edits map to the matching `Role`s; `Click` and `Focus` actions are advertised on every interactive node; inbound action requests are drained at the top of each iteration and translated into a synthetic press / focus on the matching widget. The integration is documented as best-effort in `docs/architecture.md` under "Known gaps and non-goals": hierarchical nesting, per-widget accessible names, live regions and `Action::SetValue` are listed as the natural follow-ups that the foundation now supports but does not yet wire. Cross-application clipboard via `wl_data_device_manager`. A new `src/event_loop/data_device.rs` bridges the existing process-local `clipboard: String` to the Wayland selection. Outbound (Ctrl+C / Cut): after the local clipboard is populated, `publish_clipboard_selection` creates a `CopyPasteSource` offering `text/plain;charset=utf-8` and installs it as the seat's selection; `DataSourceHandler::send` writes the cached string into the fd the peer hands us. Inbound (Ctrl+V from another app): `DataDeviceHandler::selection` asks for the offered text via `WlDataOffer::receive`, spawns a tiny worker thread to drain the read pipe with a 16 MiB cap to prevent paste-bomb DoS, and posts the result back through an `mpsc::Sender` that the run loop drains each iteration into `data.clipboard`. The `clipboard:` field's doc-comment is updated to reflect the new behaviour: process-local when the compositor does not advertise the global, synchronised with the seat selection otherwise. External drag-and-drop reception. The same `data_device` module handles `DragOffer` enter / motion / leave / drop_performed: `on_drop_motion( x, y )` fires while the drag hovers over the surface, `on_drop_leave()` when it withdraws without dropping, and `on_drop_received( x, y, mime, text )` when an external payload (`text/uri-list`, `text/plain`, …) is released on top of an ltk window. The receive path reuses the same worker-thread / channel pattern as the clipboard so the run loop never blocks on the read fd. Three new `App` hooks expose the events with no-op defaults; apps that ignore them get the previous behaviour. `xdg-activation-v1`. The global is bound optionally; when it is present, `try_run` reads `$XDG_ACTIVATION_TOKEN` from the environment, removes it immediately (single-use; preventing leaks into child processes) and stashes it on `AppData::activation_token_pending`. After the first successful configure of the main surface — the earliest point at which `xdg_activation_v1.activate` is meaningful — the token is consumed once and the surface raised to focus. Compositors without the global leave `activation_state` as `None` and the inbound path silently degrades. An `App::request_activation_token` outbound path is reserved on the trait but not yet exercised here. HarfBuzz shaping. A new `src/text_shaping.rs::shape_line` drives both renderers: the logical-order string is run through `unicode-bidi`, split into per-font sub-runs, and shaped through `rustybuzz`. Each `PositionedGlyph` carries the per-font `glyph_id`, the visual advance and the ink offsets — exactly what `fontdue::Font::rasterize_indexed` needs to render Arabic connected forms, Devanagari clusters and CJK shaped glyphs correctly. The GLES atlas is re-keyed on `(glyph_id, size_bits, font_id)` so glyphs from different fonts at the same size no longer collide, and the atlas format is selected per ES profile (`GL_R8` / `GL_RED` on ES3, `GL_LUMINANCE` on ES2) — the fragment shader samples `.r` for both, since `GL_LUMINANCE` replicates the coverage byte into `.r=.g=.b`. Software path follows the same key. New `Cargo.toml` deps: `unicode-bidi = "0.3"`, `rustybuzz = "0.14"`. Multi-touch hooks. `App::on_touch_down / on_touch_move / on_touch_up( id, x, y )` expose the raw `wl_touch.id` of every secondary finger. The first finger to land remains the *primary slot* and is fed through the regular gesture machine (`on_pointer_*`, swipe, scroll, long-press, drag-and-drop). Every additional finger fires the new callbacks instead, leaving the existing single-slot behaviour untouched for apps that do not override them. This is the substrate for app-defined pinch-zoom / two-finger pan; the toolkit itself does not yet ship a built-in pinch gesture (called out in the same "Known gaps" doc section). `event_loop::frame` extracted from `draw/mod.rs`. The `draw_frame` orchestrator and its per-format SHM helper (`pick_shm_format`) move into `src/event_loop/frame.rs`, leaving `draw/` strictly responsible for per-surface paint primitives. The import in `event_loop/run.rs` is rewritten accordingly; `draw/mod.rs` shrinks from 192-line orchestrator to a thin module index. Overlay teardown safety. `AppData::discard_overlay( id )` synchronously removes a destroyed overlay from the map and rewrites every per-device focus that pointed at it (pointer, keyboard, every touch slot), migrating an in-flight long-press drag to the main surface the same way `reconcile_overlays` does. Used by the compositor-driven destruction paths (`PopupHandler::done`, `LayerShellHandler::closed`) where waiting for the next reconcile would leave a window in which `surface()` / `surface_mut()` panic. The non-panicking siblings `try_surface` / `try_surface_mut` are added for callers on async dispatch paths (IME `Done`, tooltip arm) that may race a teardown. Miscellaneous. CI: `master` → `main` to match the actual default branch. `Makefile` adds `cargo run --example dialog` to the examples target. `src/lib.rs` re-exports `widget::scroll::ScrollAxis` so apps can configure a `scroll()` axis without reaching into a `pub(crate)` module. `Cargo.toml` adds `accesskit = "0.17"` and `accesskit_unix = "0.13"`. `docs/architecture.md` gains the "Known gaps and non-goals" section that enumerates the new capabilities, what still ships flat, and what is deferred (per-widget a11y labels, primary selection, intra-process multi-touch gestures, `wp_fractional_scale_v1`).
This commit is contained in:
@@ -26,11 +26,11 @@ use calloop::timer::{ Timer, TimeoutAction };
|
||||
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
|
||||
|
||||
use crate::app::{ App, InvalidationScope };
|
||||
use crate::draw::draw_frame;
|
||||
use crate::types::Point;
|
||||
|
||||
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
use super::error::RunError;
|
||||
use super::frame::draw_frame;
|
||||
use super::invalidation::apply_invalidation;
|
||||
use super::overlays_reconcile::reconcile_overlays;
|
||||
|
||||
@@ -184,6 +184,40 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
};
|
||||
|
||||
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
|
||||
@@ -223,6 +257,18 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
current_cursor_shape: None,
|
||||
text_input_manager,
|
||||
text_input: None,
|
||||
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,
|
||||
@@ -349,6 +395,19 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
let ext: Vec<_> = data.app.poll_external();
|
||||
data.pending_msgs.extend( ext );
|
||||
|
||||
// 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();
|
||||
@@ -437,6 +496,228 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
data.overlays_dirty = false;
|
||||
}
|
||||
draw_frame( &mut data );
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user