render: mouse selection with clipboard and primary copy-paste

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I808839078ae2674caa1f1bfd7e84f3bc6a6a6964
This commit is contained in:
raf 2026-06-24 15:36:12 +03:00
commit 7887420139
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
4 changed files with 684 additions and 32 deletions

View file

@ -3,6 +3,8 @@
//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the
//! event loop, so the PTY master fd and timers share one loop.
use std::fs::File;
use std::io::{Read as _, Write as _};
use std::os::fd::OwnedFd;
use std::os::unix::process::ExitStatusExt;
use std::process::ExitCode;
@ -16,17 +18,33 @@ use crate::font::Fonts;
use crate::pty::Pty;
use crate::render::Renderer;
use crate::vt::Term;
use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{
zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
};
use smithay_client_toolkit::{
compositor::{CompositorHandler, CompositorState},
delegate_compositor, delegate_keyboard, delegate_output, delegate_pointer, delegate_registry,
delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window,
data_device_manager::{
DataDeviceManagerState, WritePipe,
data_device::{DataDevice, DataDeviceHandler},
data_offer::{DataOfferHandler, DragOffer},
data_source::{CopyPasteSource, DataSourceHandler},
},
delegate_compositor, delegate_data_device, delegate_keyboard, delegate_output,
delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,
delegate_xdg_shell, delegate_xdg_window,
output::{OutputHandler, OutputState},
primary_selection::{
PrimarySelectionManagerState,
device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},
selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},
},
registry::{ProvidesRegistryState, RegistryState},
registry_handlers,
seat::{
Capability, SeatHandler, SeatState,
keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},
pointer::{PointerEvent, PointerEventKind, PointerHandler},
pointer::{BTN_LEFT, BTN_MIDDLE, PointerEvent, PointerEventKind, PointerHandler},
},
shell::{
WaylandSurface,
@ -40,9 +58,34 @@ use smithay_client_toolkit::{
use wayland_client::{
Connection, QueueHandle,
globals::registry_queue_init,
protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm, wl_surface},
protocol::{
wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,
wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm,
wl_surface,
},
};
/// MIME types beer offers and accepts for clipboard text.
const TEXT_MIMES: &[&str] = &[
"text/plain;charset=utf-8",
"text/plain;charset=UTF-8",
"UTF8_STRING",
"STRING",
"text/plain",
"TEXT",
];
/// Pick the first MIME type we understand from an offer's advertised set.
fn pick_mime(mimes: &[String]) -> Option<String> {
mimes
.iter()
.find(|m| TEXT_MIMES.contains(&m.as_str()))
.cloned()
}
/// Max gap between clicks counted as a multi-click (ms).
const MULTI_CLICK_MS: u32 = 400;
/// Default window size in pixels before the compositor suggests one.
const DEFAULT_W: u32 = 800;
const DEFAULT_H: u32 = 600;
@ -66,6 +109,9 @@ pub fn run() -> anyhow::Result<ExitCode> {
let compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;
let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base not available")?;
let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;
let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)
.context("wl_data_device_manager not available")?;
let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh).ok();
let surface = compositor.create_surface(&qh);
let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);
@ -90,6 +136,19 @@ pub fn run() -> anyhow::Result<ExitCode> {
window,
renderer,
loop_handle: event_loop.handle(),
qh: qh.clone(),
data_device_manager,
primary_manager,
data_device: None,
primary_device: None,
copy_source: None,
primary_source: None,
clipboard: String::new(),
primary_clip: String::new(),
selecting: false,
pointer_pos: (0.0, 0.0),
last_click: None,
serial: 0,
keyboard: None,
pointer: None,
modifiers: Modifiers::default(),
@ -159,6 +218,23 @@ struct App {
window: Window,
renderer: Renderer,
loop_handle: LoopHandle<'static, App>,
qh: QueueHandle<App>,
data_device_manager: DataDeviceManagerState,
primary_manager: Option<PrimarySelectionManagerState>,
data_device: Option<DataDevice>,
primary_device: Option<PrimarySelectionDevice>,
/// Held while we own the clipboard / primary selection, serving paste reads.
copy_source: Option<CopyPasteSource>,
primary_source: Option<PrimarySelectionSource>,
clipboard: String,
primary_clip: String,
/// A left-button drag is in progress.
selecting: bool,
pointer_pos: (f64, f64),
/// Last click (time ms, abs row, col, count) for double/triple detection.
last_click: Option<(u32, usize, usize, u32)>,
/// Most recent input serial, used to claim selections.
serial: u32,
keyboard: Option<wl_keyboard::WlKeyboard>,
pointer: Option<wl_pointer::WlPointer>,
modifiers: Modifiers,
@ -240,6 +316,22 @@ impl App {
/// viewport locally; anything else is encoded to the shell and snaps the
/// viewport back to the live screen.
fn handle_key(&mut self, event: &KeyEvent) {
// Ctrl+Shift+C/V copy the selection and paste the clipboard; these take
// precedence over the control bytes the chord would otherwise encode.
if self.modifiers.ctrl && self.modifiers.shift {
match event.keysym {
Keysym::C | Keysym::c => {
let qh = self.qh.clone();
self.set_clipboard(&qh);
return;
}
Keysym::V | Keysym::v => {
self.paste_clipboard();
return;
}
_ => {}
}
}
if self.modifiers.shift && matches!(event.keysym, Keysym::Page_Up | Keysym::Page_Down) {
if let Some(session) = self.session.as_mut() {
let page = session.term.page() as isize;
@ -262,6 +354,7 @@ impl App {
&& let Some(session) = self.session.as_mut()
{
session.term.scroll_to_bottom();
session.term.grid_mut().clear_selection();
self.dirty = true;
if let Err(err) = write_all(session.pty.master(), &bytes) {
tracing::warn!("write key to pty: {err}");
@ -269,6 +362,201 @@ impl App {
}
}
/// Map window pixel coordinates to an absolute `(row, col)` grid point.
fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {
let session = self.session.as_ref()?;
let m = self.renderer.metrics();
let grid = session.term.grid();
let col = (px.max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));
let vrow = (py.max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));
Some((grid.view_to_abs(vrow), col))
}
/// Left-button press: start (or word/line-extend) a selection.
fn pointer_press(&mut self, time: u32) {
let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {
return;
};
let count = match self.last_click {
Some((t, r, c, n))
if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>
{
n % 3 + 1
}
_ => 1,
};
self.last_click = Some((time, row, col, count));
let Some(session) = self.session.as_mut() else {
return;
};
let grid = session.term.grid_mut();
match count {
2 => grid.select_word(row, col),
3 => grid.select_line(row),
_ => grid.start_selection(row, col),
}
self.selecting = true;
self.dirty = true;
}
/// Pointer motion during a drag: extend the selection head.
fn pointer_drag(&mut self) {
if !self.selecting {
return;
}
let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {
return;
};
if let Some(session) = self.session.as_mut() {
session.term.grid_mut().extend_selection(row, col);
self.dirty = true;
}
}
/// Left-button release: a completed selection becomes the primary selection.
fn pointer_release(&mut self, qh: &QueueHandle<App>) {
if !self.selecting {
return;
}
self.selecting = false;
self.set_primary(qh);
}
/// The current selection text, if any and non-empty.
fn selection_text(&self) -> Option<String> {
let text = self.session.as_ref()?.term.grid().selection_text()?;
(!text.is_empty()).then_some(text)
}
/// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).
fn set_clipboard(&mut self, qh: &QueueHandle<App>) {
let Some(text) = self.selection_text() else {
return;
};
let Some(device) = self.data_device.as_ref() else {
return;
};
let source = self
.data_device_manager
.create_copy_paste_source(qh, TEXT_MIMES.iter().copied());
source.set_selection(device, self.serial);
self.clipboard = text;
self.copy_source = Some(source);
}
/// Claim the primary selection with the current selection (select-to-copy).
fn set_primary(&mut self, qh: &QueueHandle<App>) {
let Some(text) = self.selection_text() else {
return;
};
let (Some(manager), Some(device)) =
(self.primary_manager.as_ref(), self.primary_device.as_ref())
else {
return;
};
let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());
source.set_selection(device, self.serial);
self.primary_clip = text;
self.primary_source = Some(source);
}
/// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).
fn paste_clipboard(&mut self) {
let Some(offer) = self
.data_device
.as_ref()
.and_then(|d| d.data().selection_offer())
else {
return;
};
let Some(mime) = offer.with_mime_types(pick_mime) else {
return;
};
if let Ok(pipe) = offer.receive(mime) {
self.read_paste(pipe);
}
}
/// Paste the primary selection into the shell (middle click).
fn paste_primary(&mut self) {
let Some(offer) = self
.primary_device
.as_ref()
.and_then(|d| d.data().selection_offer())
else {
return;
};
let Some(mime) = offer.with_mime_types(pick_mime) else {
return;
};
if let Ok(pipe) = offer.receive(mime) {
self.read_paste(pipe);
}
}
/// Drain a clipboard read-pipe on the event loop, writing the bytes to the
/// PTY once the source closes its end.
fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {
let mut data: Vec<u8> = Vec::new();
let registered = self
.loop_handle
.insert_source(pipe, move |_, file, app: &mut App| {
// SAFETY: the file is owned by the source and not closed while read.
let f: &mut File = unsafe { file.get_mut() };
let mut tmp = [0u8; 4096];
match f.read(&mut tmp) {
Ok(0) => {
app.paste_bytes(&data);
PostAction::Remove
}
Ok(n) => {
data.extend_from_slice(&tmp[..n]);
PostAction::Continue
}
Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {
PostAction::Continue
}
Err(e) => {
tracing::warn!("read paste pipe: {e}");
PostAction::Remove
}
}
});
if let Err(err) = registered {
tracing::warn!("register paste pipe: {err}");
}
}
/// Write pasted bytes to the PTY, framing them for bracketed-paste mode and
/// snapping the viewport to the live screen.
fn paste_bytes(&mut self, data: &[u8]) {
let Some(session) = self.session.as_mut() else {
return;
};
session.term.scroll_to_bottom();
self.dirty = true;
let bracketed = session.term.grid().bracketed_paste();
// Strip control bytes a terminal must never receive raw from a paste;
// keep tab and newlines (CR is what the shell expects for Enter).
let mut clean: Vec<u8> = Vec::with_capacity(data.len());
for &b in data {
match b {
b'\n' => clean.push(b'\r'),
b'\t' | b'\r' => clean.push(b),
0x20..=0xff => clean.push(b),
_ => {}
}
}
let fd = session.pty.master();
if bracketed {
let _ = write_all(fd, b"\x1b[200~");
let _ = write_all(fd, &clean);
let _ = write_all(fd, b"\x1b[201~");
} else if let Err(err) = write_all(fd, &clean) {
tracing::warn!("write paste to pty: {err}");
}
}
/// After parsing child output: send any replies, sync the title, repaint.
fn after_feed(&mut self) {
let Some(session) = self.session.as_mut() else {
@ -452,6 +740,15 @@ impl SeatHandler for App {
seat: wl_seat::WlSeat,
capability: Capability,
) {
if self.data_device.is_none() {
self.data_device = Some(self.data_device_manager.get_data_device(qh, &seat));
}
if self.primary_device.is_none() {
self.primary_device = self
.primary_manager
.as_ref()
.map(|m| m.get_selection_device(qh, &seat));
}
if capability == Capability::Keyboard && self.keyboard.is_none() {
// get_keyboard_with_repeat drives key repeat off a calloop timer and
// delivers each repeat through the callback.
@ -508,10 +805,11 @@ impl KeyboardHandler for App {
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
_: u32,
serial: u32,
_: &[u32],
_: &[Keysym],
) {
self.serial = serial;
self.focused = true;
self.dirty = true;
}
@ -533,9 +831,10 @@ impl KeyboardHandler for App {
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
serial: u32,
event: KeyEvent,
) {
self.serial = serial;
self.handle_key(&event);
}
@ -588,36 +887,59 @@ impl PointerHandler for App {
fn pointer_frame(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
qh: &QueueHandle<Self>,
_: &wl_pointer::WlPointer,
events: &[PointerEvent],
) {
let cell_h = f64::from(self.renderer.metrics().height);
for event in events {
let PointerEventKind::Axis { vertical, .. } = &event.kind else {
continue;
};
// Wheel notches arrive as value120 (÷120) or legacy discrete steps,
// ~3 lines each; touchpads send pixels, mapped per cell height.
let (raw, scale) = if vertical.value120 != 0 {
(f64::from(vertical.value120) / 120.0, 3.0)
} else if vertical.discrete != 0 {
(f64::from(vertical.discrete), 3.0)
} else if cell_h > 0.0 {
(vertical.absolute / cell_h, 1.0)
} else {
continue;
};
if raw == 0.0 {
continue;
}
// Positive axis = scroll down (toward live); we scroll the viewport
// the opposite way (negative offset delta).
let lines = (raw.abs() * scale).ceil().max(1.0) as isize;
let delta = if raw < 0.0 { lines } else { -lines };
if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta);
self.dirty = true;
match &event.kind {
PointerEventKind::Enter { .. } | PointerEventKind::Motion { .. } => {
self.pointer_pos = event.position;
self.pointer_drag();
}
PointerEventKind::Press {
button,
serial,
time,
..
} => {
self.serial = *serial;
self.pointer_pos = event.position;
match *button {
BTN_LEFT => self.pointer_press(*time),
BTN_MIDDLE => self.paste_primary(),
_ => {}
}
}
PointerEventKind::Release { button, .. } if *button == BTN_LEFT => {
self.pointer_release(qh);
}
PointerEventKind::Axis { vertical, .. } => {
// Wheel notches arrive as value120 (÷120) or legacy discrete
// steps, ~3 lines each; touchpads send pixels per cell height.
let (raw, scale) = if vertical.value120 != 0 {
(f64::from(vertical.value120) / 120.0, 3.0)
} else if vertical.discrete != 0 {
(f64::from(vertical.discrete), 3.0)
} else if cell_h > 0.0 {
(vertical.absolute / cell_h, 1.0)
} else {
continue;
};
if raw == 0.0 {
continue;
}
// Positive axis = scroll down (toward live); the viewport
// scrolls the opposite way (negative offset delta).
let lines = (raw.abs() * scale).ceil().max(1.0) as isize;
let delta = if raw < 0.0 { lines } else { -lines };
if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta);
self.dirty = true;
}
}
_ => {}
}
}
}
@ -642,6 +964,134 @@ impl ProvidesRegistryState for App {
registry_handlers![OutputState, SeatState];
}
/// Serve the held clipboard text when a paste target requests it.
fn serve(text: &str, fd: WritePipe) {
let mut file = File::from(OwnedFd::from(fd));
let _ = file.write_all(text.as_bytes());
}
impl DataDeviceHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataDevice,
_: f64,
_: f64,
_: &wl_surface::WlSurface,
) {
}
fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}
fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
}
impl DataOfferHandler for App {
fn source_actions(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
fn selected_action(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
}
impl DataSourceHandler for App {
fn accept_mime(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataSource,
_: Option<String>,
) {
}
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &WlDataSource,
_mime: String,
fd: WritePipe,
) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.clipboard, fd);
}
}
fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.copy_source = None;
}
}
fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}
}
impl PrimarySelectionDeviceHandler for App {
fn selection(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &ZwpPrimarySelectionDeviceV1,
) {
}
}
impl PrimarySelectionSourceHandler for App {
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
_mime: String,
fd: WritePipe,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.primary_clip, fd);
}
}
fn cancelled(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.primary_source = None;
}
}
}
delegate_compositor!(App);
delegate_output!(App);
delegate_shm!(App);
@ -650,4 +1100,6 @@ delegate_keyboard!(App);
delegate_pointer!(App);
delegate_xdg_shell!(App);
delegate_xdg_window!(App);
delegate_data_device!(App);
delegate_primary_selection!(App);
delegate_registry!(App);