event_loop, a11y, text_shaping: AccessKit AT-SPI2 bridge, cross-app clipboard, xdg-activation, HarfBuzz shaping, multi-touch hooks
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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:
2026-05-16 22:09:59 +02:00
parent 4aa3480b64
commit 4a80165428
48 changed files with 3088 additions and 645 deletions
+147 -6
View File
@@ -80,6 +80,56 @@ pub struct AppData<A: App>
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// `xdg-activation-v1`. Present when the compositor advertises the
/// global. Used on first configure to honour an incoming
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
/// main surface raised to focus) and exposed via
/// [`App::request_activation_token`] for outbound requests.
pub activation_state:
Option<smithay_client_toolkit::activation::ActivationState>,
/// Honour the activation token exactly once — after the first
/// successful configure of the main surface. Subsequent configures
/// (resizes, scale changes) must not re-activate.
pub activation_token_pending: Option<String>,
/// `wl_data_device_manager` binding. `None` when the compositor
/// does not advertise the global, in which case copy / paste stays
/// process-local and inbound selections from other clients are
/// invisible.
pub data_device_manager:
Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
/// Per-seat `wl_data_device` handle, created the first time a seat
/// with a keyboard or pointer capability appears. Required for
/// `set_selection` and to receive inbound `selection` events.
pub data_device:
Option<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
/// Currently-published outbound selection source. Held alive
/// across [`DataSourceHandler::send_request`] so the cached
/// clipboard text can be re-served on each paste from the peer.
pub clipboard_source:
Option<smithay_client_toolkit::data_device_manager::data_source::CopyPasteSource>,
/// Sender half of the cross-thread channel used to ferry inbound
/// selection bytes (a worker thread drains the read pipe).
pub clipboard_inbox_tx: std::sync::mpsc::Sender<String>,
/// Receiver half — drained once per run loop iteration.
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
/// Cross-application drag-and-drop: best mime negotiated on
/// `enter` and the last pointer position (in logical surface
/// coords). Cleared on `leave` and after `drop_performed`.
pub drop_position: Option<( f64, f64 )>,
pub drop_mime: Option<String>,
pub drop_inbox_tx: std::sync::mpsc::Sender<super::data_device::DropPayload>,
pub drop_inbox_rx: std::sync::mpsc::Receiver<super::data_device::DropPayload>,
/// AccessKit / AT-SPI2 adapter. `None` when the platform adapter
/// could not be created (no AT-SPI2 daemon on the session bus,
/// missing system libraries at runtime, headless CI). The
/// runtime carries on without an accessibility tree in that
/// case; nothing else in the pipeline reads this field except
/// the per-frame tree update and the per-iteration action
/// inbox drain.
pub a11y: Option<crate::a11y::A11yState>,
/// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the
/// global proxy plus the live list of open toplevels; SCTK fans
/// events through this object into our `ForeignToplevelListHandler`
@@ -113,12 +163,13 @@ pub struct AppData<A: App>
/// pointer leaving the surface, a gesture cancel, focus loss or
/// long-press promotion.
pub button_repeat: Option<ButtonRepeatState>,
/// Process-local clipboard buffer. Copy / Cut store the selected
/// text here; Paste retrieves from here. Cross-application
/// clipboard via `wl_data_device_manager` is intentionally not
/// wired up — most app workflows need a same-process buffer first
/// (move text between fields, undo a delete by paste-back) and
/// the Wayland integration is a sizeable separate piece.
/// Clipboard buffer. Copy / Cut store the selected text here;
/// Paste retrieves from here. Synchronised with the Wayland
/// selection through `wl_data_device_manager` when the compositor
/// advertises the global: outbound copies publish a
/// `CopyPasteSource`, and inbound selections from other clients
/// land here through a worker thread (see
/// [`super::data_device`]).
pub clipboard: String,
/// Timestamp of the previous press (pointer or touch). Combined
/// with [`Self::last_press_pos`] it lets the press handler
@@ -222,6 +273,18 @@ impl<A: App> AppData<A>
}
}
/// Non-panicking variant of [`surface`]. Returns `None` when `focus`
/// refers to an overlay that has already been removed — callers on
/// async dispatch paths (IME Done, tooltip arm) must use this.
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message>>
{
match focus
{
SurfaceFocus::Main => Some( &self.main ),
SurfaceFocus::Overlay( id ) => self.overlays.get( &id ),
}
}
/// Mutable counterpart of [`surface`].
#[allow( dead_code )]
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message>
@@ -234,12 +297,90 @@ impl<A: App> AppData<A>
}
}
/// Non-panicking variant of [`surface_mut`].
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message>>
{
match focus
{
SurfaceFocus::Main => Some( &mut self.main ),
SurfaceFocus::Overlay( id ) => self.overlays.get_mut( &id ),
}
}
/// Synchronous overlay teardown: removes the overlay from the map
/// and rewrites every per-device focus that pointed at it so the
/// next event in the same dispatch can no longer land on a freed
/// surface. Used by the compositor-driven destruction paths
/// (`PopupHandler::done`, `LayerShellHandler::closed`) where
/// waiting for the next `reconcile_overlays` would leave a window
/// in which `surface()` / `surface_mut()` panic. Migrates an
/// in-flight long-press drag to the main surface for the same
/// reason `reconcile_overlays` does.
pub( crate ) fn discard_overlay( &mut self, id: crate::app::OverlayId )
{
if let Some( ss ) = self.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
self.main.gesture.long_press_fired = true;
self.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
if let SurfaceFocus::Overlay( fid ) = self.pointer_focus
{
if fid == id { self.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( fid ) = self.keyboard_focus
{
if fid == id { self.keyboard_focus = SurfaceFocus::Main; }
}
for f in self.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( fid ) = *f
{
if fid == id { *f = SurfaceFocus::Main; }
}
}
if let Some( pending ) = self.tooltip_pending.as_ref()
{
if pending.focus == SurfaceFocus::Overlay( id ) { self.tooltip_pending = None; }
}
if let Some( visible ) = self.tooltip_visible.as_ref()
{
if visible.focus == SurfaceFocus::Overlay( id )
{
self.tooltip_visible = None;
self.overlays_dirty = true;
}
}
}
// Configure the main surface size, (re)allocate its rendering target, and
// request a redraw. Routes to the GPU or SHM path inside `SurfaceState`
// according to whether `self.egl_context` is available.
pub( crate ) fn on_configure( &mut self, w: u32, h: u32 )
{
self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
if let Some( ref mut a ) = self.a11y
{
let sf = self.main.scale_factor.max( 1 ) as f64;
a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf );
a.set_window_focus( true );
}
// First-configure latch: honour an incoming
// `XDG_ACTIVATION_TOKEN` exactly once, after the surface has
// been mapped (`xdg_activation_v1.activate` is only meaningful
// against a configured surface). Compositors that don't
// advertise the global leave `activation_state` as `None` and
// the call drops the token silently.
if let Some( token ) = self.activation_token_pending.take()
{
if let ( Some( ref activation ), Some( wl ) ) =
( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
}
}
// `on_resize` is documented to deliver **physical** pixels, matching the
// coordinate space that the layout passes and the pointer/touch
// callbacks (`on_drag_move`, `on_drop`) work in. Wayland's
+2
View File
@@ -15,6 +15,7 @@ impl<A: App> AppData<A>
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
self.publish_clipboard_selection();
}
}
@@ -26,6 +27,7 @@ impl<A: App> AppData<A>
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
self.publish_clipboard_selection();
}
if let Some( new_value ) = self.delete_selection( focus )
{
+251
View File
@@ -0,0 +1,251 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `wl_data_device_manager` integration — cross-application
//! clipboard.
//!
//! This module bridges ltk's process-local `clipboard: String` to the
//! Wayland selection so that:
//!
//! * **Outbound (Ctrl+C / Cut)** — after the local clipboard is
//! populated, [`AppData::publish_clipboard_selection`] creates a
//! [`CopyPasteSource`] offering `text/plain;charset=utf-8` and
//! installs it as the seat's selection. When a peer pastes,
//! [`DataSourceHandler::send`] writes the cached string into the
//! fd the peer hands us.
//!
//! * **Inbound (Ctrl+V from another app)** — when the compositor
//! advertises a new selection through
//! [`DataDeviceHandler::selection`], we ask for it as UTF-8 text
//! via `WlDataOffer::receive`, spawn a tiny thread to drain the
//! read pipe, and post the result back to the event loop through
//! the runtime's existing `ChannelSender`-style channel. The
//! message just updates `self.clipboard`; subsequent local pastes
//! pick the new value up the next time the user hits Ctrl+V.
//!
//! Drag-and-drop across applications uses the same protocol but
//! involves enter / leave / motion / drop_performed wiring with
//! widget hit-tests, surface coordinates and drop targets that are a
//! separate concern. The handlers in this file accept those events
//! but currently route them to no-ops; an explicit follow-up will
//! cover DnD targets.
use std::io::Read;
use std::sync::mpsc;
use smithay_client_toolkit::data_device_manager::data_device::{ DataDeviceData, DataDeviceHandler };
use smithay_client_toolkit::data_device_manager::data_offer::{ DataOfferHandler, DragOffer, SelectionOffer };
use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler;
use smithay_client_toolkit::reexports::client::
{
protocol::
{
wl_data_device::WlDataDevice,
wl_data_device_manager::DndAction,
wl_data_source::WlDataSource,
wl_surface::WlSurface,
},
Connection, Proxy, QueueHandle,
};
use crate::app::App;
use super::app_data::AppData;
/// MIME type we advertise on outbound copies and request on inbound
/// pastes. `text/plain;charset=utf-8` is the de-facto cross-toolkit
/// baseline (GTK, Qt, electron, Firefox all carry it); `UTF8_STRING`
/// is the X11-era legacy alias still emitted by some compositors.
pub( super ) const CLIPBOARD_MIMES: &[ &str ] = &[ "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
/// Mimes accepted for inter-application drag-and-drop. URI lists
/// (file managers, browsers) come first because they are the most
/// useful payload for typical desktop drops.
pub( super ) const DND_MIMES: &[ &str ] = &[ "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
/// Payload delivered from the DnD worker thread back to the run loop.
pub( crate ) struct DropPayload
{
pub mime: String,
pub text: String,
pub x: f64,
pub y: f64,
}
pub( crate ) fn drop_inbox() -> ( std::sync::mpsc::Sender<DropPayload>, std::sync::mpsc::Receiver<DropPayload> )
{
std::sync::mpsc::channel()
}
impl<A: App> AppData<A>
{
/// Publish the current `self.clipboard` value as the seat's
/// selection so other applications can paste it. Called by the
/// copy / cut handlers after they populate the local buffer.
/// No-op when the compositor does not advertise
/// `wl_data_device_manager` or when no seat has appeared yet.
pub( crate ) fn publish_clipboard_selection( &mut self )
{
let ( Some( ref ddm ), Some( ref dd ) ) = ( self.data_device_manager.as_ref(), self.data_device.as_ref() ) else { return };
let source = ddm.create_copy_paste_source( &self.qh, CLIPBOARD_MIMES.iter().copied() );
source.set_selection( dd, self.last_input_serial );
// Hold the source alive: the compositor only keeps the
// selection while the `WlDataSource` is not dropped. We also
// need to hang on to it so `DataSourceHandler::send` can find
// the cached text via `self.clipboard`.
self.clipboard_source = Some( source );
}
}
impl<A: App> DataDeviceHandler for AppData<A>
{
fn enter(
&mut self,
_c: &Connection,
_qh: &QueueHandle<Self>,
d: &WlDataDevice,
x: f64,
y: f64,
_s: &WlSurface,
)
{
let Some( data ) = d.data::<DataDeviceData>() else { return };
let Some( offer ) = data.drag_offer() else { return };
let mimes = offer.with_mime_types( |m| m.to_vec() );
let accepted = DND_MIMES.iter().copied()
.find( |c| mimes.iter().any( |m| m == *c ) )
.map( |s| s.to_string() );
offer.accept_mime_type( self.last_input_serial, accepted.clone() );
offer.set_actions( smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy,
smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy );
self.drop_position = Some( ( x, y ) );
self.drop_mime = accepted;
self.app.on_drop_motion( x as f32, y as f32 );
}
fn leave( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice )
{
if self.drop_position.is_some() { self.app.on_drop_leave(); }
self.drop_position = None;
self.drop_mime = None;
}
fn motion( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice, x: f64, y: f64 )
{
self.drop_position = Some( ( x, y ) );
self.app.on_drop_motion( x as f32, y as f32 );
}
fn drop_performed( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, d: &WlDataDevice )
{
let Some( data ) = d.data::<DataDeviceData>() else { return };
let Some( offer ) = data.drag_offer() else { return };
let Some( mime ) = self.drop_mime.clone() else { offer.finish(); return };
let ( x, y ) = self.drop_position.unwrap_or( ( 0.0, 0.0 ) );
let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return };
offer.finish();
let tx = self.drop_inbox_tx.clone();
std::thread::spawn( move ||
{
use std::io::Read;
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
let mut buf = String::new();
let mut bounded = ( &mut reader ).take( 16 * 1024 * 1024 );
let _ = bounded.read_to_string( &mut buf );
let _ = tx.send( DropPayload { mime, text: buf, x, y } );
} );
self.drop_position = None;
self.drop_mime = None;
}
/// A new selection arrived from another client. Negotiate
/// UTF-8 text on a background thread (the receive side of the
/// data offer is a blocking read pipe) and post the result back
/// into the event loop through `clipboard_inbox`. The main loop
/// drains the inbox once per iteration and copies the bytes
/// into `self.clipboard`.
fn selection( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, data_device: &WlDataDevice )
{
let Some( data ) = data_device.data::<DataDeviceData>() else { return };
let Some( offer ) = data.selection_offer() else { return };
let Some( mime ) = pick_mime( &offer ) else { return };
let Ok( pipe ) = offer.receive( mime ) else { return };
let tx = self.clipboard_inbox_tx.clone();
std::thread::spawn( move ||
{
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
let mut buf = String::new();
// 16 MiB cap. Compositors are free to keep typing into the
// pipe forever; a runaway selection should not exhaust
// memory or stall the worker thread indefinitely.
let mut bounded = (&mut reader).take( 16 * 1024 * 1024 );
let _ = bounded.read_to_string( &mut buf );
let _ = tx.send( buf );
} );
}
}
impl<A: App> DataOfferHandler for AppData<A>
{
fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
}
impl<A: App> DataSourceHandler for AppData<A>
{
fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _mime: Option<String> ) {}
fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource, _mime: String, fd: smithay_client_toolkit::data_device_manager::WritePipe )
{
// Only honour requests for our own active source — if a stale
// source is still hanging around (e.g. between a new copy and
// the compositor dropping the previous selection) the peer
// might still ask for its data; we just write nothing rather
// than leak the new clipboard contents through an old source.
let is_active = self.clipboard_source.as_ref()
.map( |s| s.inner() == source )
.unwrap_or( false );
if !is_active { return; }
// SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File`
// (no buffering — selection payloads are small) and write the
// cached clipboard string. Errors (peer closed early, broken
// pipe) are ignored: we cannot recover and the compositor
// will issue a fresh source on the next copy anyway.
use std::io::Write;
let mut writer = std::fs::File::from( std::os::fd::OwnedFd::from( fd ) );
let _ = writer.write_all( self.clipboard.as_bytes() );
}
fn cancelled( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource )
{
// Compositor dropped our source (another client claimed the
// selection). Drop the cached handle so a future copy creates
// a fresh one.
if self.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false )
{
self.clipboard_source = None;
}
}
fn dnd_dropped( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
fn action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _action: DndAction ) {}
}
/// Pick the best-supported MIME type from a selection offer. Order
/// matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text, then
/// the legacy `UTF8_STRING`.
fn pick_mime( offer: &SelectionOffer ) -> Option<String>
{
let mimes = offer.with_mime_types( |m| m.to_vec() );
CLIPBOARD_MIMES.iter()
.find( |c| mimes.iter().any( |m| m == *c ) )
.map( |s| s.to_string() )
}
/// Buffered channel pair used to ferry inbound-selection bytes from
/// the read-pipe worker thread to the event loop. The receiver is
/// drained once per iteration inside the main run loop.
pub( crate ) fn clipboard_inbox() -> ( mpsc::Sender<String>, mpsc::Receiver<String> )
{
mpsc::channel()
}
+158
View File
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use smithay_client_toolkit::compositor::CompositorState;
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
use smithay_client_toolkit::shm::Shm;
use crate::app::App;
use crate::egl_context::EglContext;
use crate::types::{ Color, Rect };
use crate::widget::Element;
use super::AppData;
use super::surface::{ SurfaceFocus, SurfaceState };
use crate::draw::gles::{ draw_surface_full_gpu, draw_surface_partial_gpu };
use crate::draw::software::{ draw_surface_full, draw_surface_partial };
use crate::draw::damage::compute_interaction_dirty_rects;
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems (plain
/// memcpy). Falls back to mandatory Argb8888 when not advertised.
/// Returns `(format, swap_rb)`.
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
{
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
{
( wl_shm::Format::Abgr8888, false )
} else {
( wl_shm::Format::Argb8888, true )
}
}
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> )
{
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
let main_bg = data.app.background_color();
let main_region = data.app.input_region();
let debug_layout = data.debug_layout;
let ( format, swap_rb ) = pick_shm_format( &data.shm );
let egl_ctx = data.egl_context.as_ref();
let qh = &data.qh;
if data.main.configured && data.main.needs_redraw && !data.main.frame_pending
{
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
draw_surface::<A::Message>(
&mut data.main,
&data.compositor_state,
egl_ctx,
main_view,
main_bg,
main_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
data.main.needs_redraw = false;
data.main.last_draw = std::time::Instant::now();
}
for spec in overlays
{
if let Some( ss ) = data.overlays.get_mut( &spec.id )
{
if !ss.configured || !ss.needs_redraw || ss.frame_pending { continue; }
let focus = SurfaceFocus::Overlay( spec.id );
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
draw_surface::<A::Message>(
ss,
&data.compositor_state,
egl_ctx,
&spec.view,
bg,
spec.input_region.as_deref(),
debug_layout,
format,
swap_rb,
&req_frame,
);
ss.needs_redraw = false;
ss.last_draw = std::time::Instant::now();
}
}
}
fn draw_surface<Msg: Clone>(
ss: &mut SurfaceState<Msg>,
compositor: &CompositorState,
egl_ctx: Option<&Arc<EglContext>>,
view: &Element<Msg>,
bg: Color,
input_region: Option<&[Rect]>,
debug_layout: bool,
shm_format: wl_shm::Format,
swap_rb: bool,
request_frame: &dyn Fn( &WlSurface ),
)
{
let scale = ss.scale_factor.max( 1 ) as u32;
let w = ss.width;
let h = ss.height;
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
let canvas_ready = ss.canvas.as_ref()
.map( |c| c.size() == ( pw, ph ) )
.unwrap_or( false );
let partial_eligible = !ss.content_dirty
&& canvas_ready
&& !ss.widget_rects.is_empty();
if partial_eligible
{
let dirty_rects = compute_interaction_dirty_rects(
&ss.widget_rects,
ss.prev_focused, ss.prev_hovered, ss.prev_pressed,
ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx,
pw, ph,
);
if dirty_rects.is_empty() { return; }
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
if total < pw as f32 * ph as f32 * 0.5
{
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
draw_surface_partial_gpu(
ss, compositor, ctx, view, bg, input_region,
dirty_rects, pw, ph, scale, request_frame,
);
} else {
draw_surface_partial(
ss, compositor, view, bg, input_region,
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
);
}
return;
}
}
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
{
draw_surface_full_gpu(
ss, compositor, ctx, view, bg, input_region, debug_layout,
pw, ph, scale, request_frame,
);
} else {
draw_surface_full(
ss, compositor, view, bg, input_region, debug_layout,
shm_format, swap_rb, pw, ph, scale, request_frame,
);
}
}
+56 -13
View File
@@ -143,10 +143,15 @@ impl<A: App> LayerShellHandler for AppData<A>
}
Some( super::SurfaceFocus::Overlay( id ) ) =>
{
// Compositor asked us to destroy this overlay. Remove it;
// the next reconcile will not recreate it unless the app
// still returns its id from `App::overlays()`.
self.overlays.remove( &id );
// Compositor asked us to destroy this overlay. Drop it
// synchronously *and* rewrite every per-device focus that
// pointed at it — otherwise the next event in this same
// dispatch (touch up, IME done, pointer leave) lands on a
// freed surface and `surface()` / `surface_mut()` panic.
// `reconcile_overlays` will still run afterwards and skip
// re-creating the entry as long as the app stops returning
// its id from `overlays()`.
self.discard_overlay( id );
}
}
}
@@ -266,8 +271,23 @@ impl<A: App> SeatHandler for AppData<A>
}
fn new_seat(
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
) {}
&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: WlSeat,
)
{
// First seat → first data device. We only need one device
// regardless of how many seats appear (the typical desktop
// session has exactly one); additional seats reuse the same
// device-bound clipboard semantics by construction because
// `wl_data_device_manager.get_data_device` takes the seat as
// argument and the compositor manages routing.
if self.data_device.is_none()
{
if let Some( ref ddm ) = self.data_device_manager
{
self.data_device = Some( ddm.get_data_device( qh, &seat ) );
}
}
}
fn new_capability(
&mut self,
@@ -369,7 +389,9 @@ impl<A: App> PopupHandler for AppData<A>
{
self.pending_msgs.push( msg );
}
self.overlays.remove( &id );
// Synchronous teardown plus per-device focus cleanup. See
// `discard_overlay` for the panic case this prevents.
self.discard_overlay( id );
}
}
}
@@ -389,6 +411,27 @@ delegate_xdg_window!( @<A: App> AppData<A> );
delegate_xdg_popup!( @<A: App> AppData<A> );
delegate_foreign_toplevel_list!( @<A: App> AppData<A> );
delegate_registry!( @<A: App> AppData<A> );
smithay_client_toolkit::delegate_activation!( @<A: App> AppData<A> );
smithay_client_toolkit::delegate_data_device!( @<A: App> AppData<A> );
// --- `xdg-activation-v1` handler ---
//
// We only honour inbound activation here: when a token issued for our
// own request gets delivered, we activate the main surface. Outbound
// requests (so the app can pass a token to another app) are out of
// scope for now — adding them only requires a new public method on
// `AppData` and an extra trait method on `App`.
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
{
type RequestData = smithay_client_toolkit::activation::RequestData;
fn new_token( &mut self, token: String, _data: &Self::RequestData )
{
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
{
activation.activate::<Self>( &wl, token );
}
}
}
impl<A: App> ProvidesRegistryState for AppData<A>
{
@@ -581,16 +624,16 @@ impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
}
}
}
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, .. } =>
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } =>
{
for _ in 0..before_length
{
state.handle_backspace( focus );
}
state.handle_delete_surrounding( focus, before_length, after_length );
}
zwp_text_input_v3::Event::Done { .. } =>
{
state.surface_mut( focus ).request_redraw();
if let Some( ss ) = state.try_surface_mut( focus )
{
ss.request_redraw();
}
}
_ => {}
}
+2
View File
@@ -3,6 +3,7 @@
pub( crate ) mod app_data;
pub( crate ) mod clipboard;
pub( crate ) mod data_device;
pub( crate ) mod context_menu;
pub( crate ) mod cursor_shape;
pub( crate ) mod drag;
@@ -14,6 +15,7 @@ pub( crate ) mod text_editing;
pub( crate ) mod tooltip;
pub( crate ) mod error;
pub( crate ) mod frame;
pub( crate ) mod run;
pub( crate ) mod invalidation;
pub( crate ) mod overlays_reconcile;
+282 -1
View File
@@ -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.
+57 -13
View File
@@ -178,8 +178,14 @@ pub( crate ) struct SurfaceState<Msg: Clone>
/// cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
pub pending_text_values: HashMap<usize, String>,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<( Rect, usize )>,
/// Per-scroll-viewport offset, indexed by `flat_idx` of the
/// `Scroll` widget. Tuple is `(x, y)` in physical pixels: x is
/// applied only when the widget's axis allows horizontal scroll
/// (and stays at `0` otherwise), y mirrors the historic single-f32
/// behaviour for vertical scrolls.
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
pub scroll_offsets: HashMap<usize, ( f32, f32 )>,
pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
/// every interactive item the scroll's child laid out, in document
@@ -197,6 +203,18 @@ pub( crate ) struct SurfaceState<Msg: Clone>
/// (`src/input/`) calls the lifecycle methods; the long-press
/// deadline poller reaches into `gesture.long_press_*` directly.
pub gesture: GestureState<Msg>,
/// `wl_touch.id` of the finger currently driving the single-slot
/// gesture machine. `None` when no finger is down on this surface.
/// Subsequent fingers landing while this is `Some` route through
/// the auxiliary multi-touch path ([`App::on_touch_down`] etc.)
/// instead of being absorbed into the primary slot.
pub primary_touch_id: Option<i32>,
/// Auxiliary touch slots: every non-primary finger's last known
/// position, keyed by `wl_touch.id`. Used by the touch handler to
/// generate `on_touch_up` with the correct release point (Wayland
/// `wl_touch.up` does not carry a position) and to keep state
/// across `motion` events between dispatchers.
pub touch_slots: HashMap<i32, crate::types::Point>,
/// Previous frame interaction state for damage tracking
pub prev_focused: Option<usize>,
pub prev_hovered: Option<usize>,
@@ -253,9 +271,12 @@ impl<Msg: Clone> SurfaceState<Msg>
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
pending_text_values: HashMap::new(),
accessible_extras: Vec::new(),
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_navigable_items: HashMap::new(),
primary_touch_id: None,
touch_slots: HashMap::new(),
context_menu: None,
scroll_canvases: HashMap::new(),
gesture: GestureState::new(),
@@ -311,11 +332,20 @@ impl<Msg: Clone> SurfaceState<Msg>
h: u32,
)
{
self.width = w;
self.height = h;
let scale = self.scale_factor.max( 1 ) as u32;
let pw = w * scale;
let ph = h * scale;
let scale = self.scale_factor.max( 1 ) as u32;
let pw = w.saturating_mul( scale );
let ph = h.saturating_mul( scale );
let pool_bytes = (pw as u64)
.saturating_mul( ph as u64 )
.saturating_mul( 4 );
const MAX_POOL: u64 = 256 * 1024 * 1024;
if pool_bytes > MAX_POOL || pw == 0 || ph == 0
{
eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" );
return;
}
let pool_bytes = pool_bytes as usize;
if self.egl_surface.is_some()
{
@@ -329,9 +359,15 @@ impl<Msg: Clone> SurfaceState<Msg>
}
} else if self.pool.is_some() {
// SHM path: reallocate the pool to fit the new buffer size.
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
match SlotPool::new( pool_bytes, shm )
{
Ok( pool ) => self.pool = Some( pool ),
Err( e ) =>
{
eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" );
return;
}
}
} else {
// First configure — pick the path. Try GPU when the bootstrap
// succeeded; fall back to SHM if surface creation fails.
@@ -353,12 +389,20 @@ impl<Msg: Clone> SurfaceState<Msg>
}
if !chose_gpu
{
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
match SlotPool::new( pool_bytes, shm )
{
Ok( pool ) => self.pool = Some( pool ),
Err( e ) =>
{
eprintln!( "[ltk] SlotPool init failed: {e}" );
return;
}
}
}
}
self.width = w;
self.height = h;
self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
self.configured = true;
self.needs_redraw = true;
@@ -130,6 +130,87 @@ impl<A: App> AppData<A>
}
}
/// Respond to `zwp_text_input_v3.delete_surrounding_text`. Both
/// arguments are **UTF-8 byte counts** measured from the cursor.
/// IMEs (Mozc, Anthy, IBus-pinyin) emit this routinely to commit a
/// preedit on top of pre-existing surrounding text. The previous
/// implementation iterated `before_length` calls to `handle_backspace`,
/// treating the byte count as a number of characters — corrupting
/// any non-ASCII input. This method does a single bytes-aware
/// replace_range that snaps to UTF-8 char boundaries.
pub( crate ) fn handle_delete_surrounding( &mut self, focus: SurfaceFocus, before_bytes: u32, after_bytes: u32 )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
let _ = self.delete_selection( focus );
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( current_value.len() );
let safe_cursor = cursor_val.min( current_value.len() );
// `before_bytes` of UTF-8 walked back from the cursor, snapping
// to char boundaries — if the byte count lands mid-codepoint we
// expand outward to swallow the whole codepoint (matches what
// GTK / Qt do under the same protocol).
let start_byte =
{
let mut pos = safe_cursor;
for ( i, _ ) in current_value[..safe_cursor].char_indices().rev()
{
if ( safe_cursor - pos ) as u32 >= before_bytes { break; }
pos = i;
}
pos
};
let end_byte =
{
let mut pos = safe_cursor;
for ( i, ch ) in current_value[safe_cursor..].char_indices()
{
if ( pos - safe_cursor ) as u32 >= after_bytes { break; }
pos = safe_cursor + i + ch.len_utf8();
}
pos
};
if start_byte >= end_byte { return; }
let mut new_value = current_value.clone();
new_value.replace_range( start_byte..end_byte, "" );
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { start_byte };
{
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
pub( crate ) fn handle_backspace( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
+2 -2
View File
@@ -30,7 +30,7 @@ impl<A: App> AppData<A>
{
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
{
let ss = self.surface( focus );
let Some( ss ) = self.try_surface( focus ) else { return };
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
let Some( text ) = w.tooltip.clone() else
{
@@ -89,7 +89,7 @@ impl<A: App> AppData<A>
{
let v = self.tooltip_visible.as_ref()?;
let ( ox, oy ) = self.surface_offset_for( v.focus );
let scale = self.surface( v.focus ).scale_factor as f32;
let scale = self.try_surface( v.focus )?.scale_factor as f32;
let anchor_x = ox + v.anchor.x / scale;
let anchor_y = oy + v.anchor.y / scale;
let anchor_w = v.anchor.width / scale;