Files
ltk/src/event_loop/data_device.rs
T
admin 99b195ea13
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
The primary selection is kept in step with other applications, an application can paste either selection and hear the middle button, raw keys carry the text dead keys compose, and an application can ask for a cursor where the pointer is
A terminal drawn by the application itself needed four things ltk did not give it: the selection the middle button pastes, a way to read the clipboard, the text a key types once dead keys are applied, and a text cursor over its text area that does not hide the window's resize cursors. Text fields gain the primary selection along the way, and the symbol fallback and a missed field in the previous commit are fixed.
The primary selection. `event_loop/primary_selection.rs` binds `zwp_primary_selection_device_manager_v1` when the compositor offers it, creates the device with the data device in `new_seat`, and holds the manager, the device, our source while we own the selection, its text and the inbox the reader threads post to. A selection offered by another client is read on a thread of its own as the clipboard is; a peer asking for ours is served from the cached text, and a cancelled source is dropped. `data_device.rs` gives both the pieces they share: `pick_text_mime`, `read_selection` and `write_selection`, so the clipboard behaves as before.
Text fields. Once a press ends, `sync_text_field` publishes a new selection in the focused text field as the primary selection; password fields never publish theirs. It remembers what it last published rather than comparing with the current primary selection, so a selection made in another application is not taken back. A middle-button press on a text field focuses it, puts the cursor where it landed and inserts the primary selection there.
The application's side. `App::take_primary_request` makes a text the primary selection, as `take_clipboard_request` does for the clipboard. `App::take_paste_request` names a `SelectionKind`, `Clipboard` or `Primary`, and the runtime answers with `App::on_paste` and that selection's text, never with an empty one. `App::on_pointer_middle` reports a middle-button press anywhere but on a text field. All default to doing nothing.
Raw keys. `App::on_raw_key_with_alt` gives way to `App::on_raw_key_event`, which takes a `RawKey` with the keysym, the keycode, the press, Ctrl, Shift and Alt, and `text`: what sctk reports the key types once dead keys and compose sequences are applied, `None` on release, halfway through a sequence and for keys that type nothing. A terminal that took the character from the keysym typed `a` for an acute accent followed by `a`. The default still forwards to `on_raw_key`, and prompt was the only application overriding the old hook. Every key press now also records its serial in `last_input_serial`, so a selection set from a keyboard shortcut carries a serial a strict compositor accepts rather than the last click's.
The cursor. `App::cursor_at( x, y )` is the cursor the application wants at a point of the main surface. It comes after the resize edges and `cursor_override`, and before the cursor of the widget under the pointer, so a terminal shows the text cursor over its cells and the resize cursors still show at the window's edges, which a `cursor_override` would hide.
Fonts. Noto Sans Symbols 2 and Noto Sans Symbols follow DejaVu Sans in the fallback chain, for the media controls, arrows and shapes DejaVu lacks, which drew as the missing glyph.
`TextEdit::map_msg` now carries `pad_h` over: the previous commit added the field but left it out there, and ltk did not build.
2026-10-14 16:41:54 +02:00

262 lines
10 KiB
Rust

// 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 };
use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler;
use smithay_client_toolkit::data_device_manager::{ ReadPipe, WritePipe };
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 ) = offer.with_mime_types( pick_text_mime ) else { return };
let Ok( pipe ) = offer.receive( mime ) else { return };
read_selection( pipe, self.clipboard_inbox_tx.clone() );
}
}
/// Drain an offered selection on a thread of its own and post the text to `tx`.
pub( super ) fn read_selection( pipe: ReadPipe, tx: mpsc::Sender<String> )
{
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 );
} );
}
/// Serve `text` to the peer that asked for one of our selections.
pub( super ) fn write_selection( pipe: WritePipe, text: &str )
{
// SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File`
// (no buffering — selection payloads are small). 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( pipe ) );
let _ = writer.write_all( text.as_bytes() );
}
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: 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; }
write_selection( fd, &self.clipboard );
}
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 those a selection offers.
/// Order matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text,
/// then the legacy `UTF8_STRING`.
pub( super ) fn pick_text_mime( mimes: &[ String ] ) -> Option<String>
{
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()
}