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.
262 lines
10 KiB
Rust
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()
|
|
}
|