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

@ -106,6 +106,14 @@ struct Cursor {
y: usize, y: usize,
} }
/// A point in the combined scrollback+live coordinate space: `row` indexes
/// scrollback lines first (oldest at 0), then the live screen.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Point {
pub row: usize,
pub col: usize,
}
/// The active screen plus cursor, scroll region, and current pen. /// The active screen plus cursor, scroll region, and current pen.
#[derive(Debug)] #[derive(Debug)]
pub struct Grid { pub struct Grid {
@ -137,6 +145,10 @@ pub struct Grid {
app_cursor: bool, app_cursor: bool,
/// Cursor colour from OSC 12; `None` follows the cell under the cursor. /// Cursor colour from OSC 12; `None` follows the cell under the cursor.
cursor_color: Option<(u8, u8, u8)>, cursor_color: Option<(u8, u8, u8)>,
/// Active mouse selection as (anchor, head) in absolute coordinates.
selection: Option<(Point, Point)>,
/// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.
bracketed_paste: bool,
} }
fn default_tabs(cols: usize) -> Vec<bool> { fn default_tabs(cols: usize) -> Vec<bool> {
@ -168,6 +180,8 @@ impl Grid {
cursor_visible: true, cursor_visible: true,
cursor_color: None, cursor_color: None,
app_cursor: false, app_cursor: false,
selection: None,
bracketed_paste: false,
} }
} }
@ -496,8 +510,13 @@ impl Grid {
let line = std::mem::replace(&mut self.lines[y], vec![Cell::default(); self.cols]); let line = std::mem::replace(&mut self.lines[y], vec![Cell::default(); self.cols]);
self.scrollback.push_back(line); self.scrollback.push_back(line);
} }
let mut evicted = 0;
while self.scrollback.len() > SCROLLBACK_CAP { while self.scrollback.len() > SCROLLBACK_CAP {
self.scrollback.pop_front(); self.scrollback.pop_front();
evicted += 1;
}
if evicted > 0 {
self.shift_selection(evicted);
} }
// Keep a scrolled-back viewport anchored to the same content. // Keep a scrolled-back viewport anchored to the same content.
if self.view_offset > 0 { if self.view_offset > 0 {
@ -697,6 +716,145 @@ impl Grid {
} }
} }
// --- selection ---
/// The absolute row currently shown at viewport row `y`.
pub fn view_to_abs(&self, y: usize) -> usize {
self.scrollback.len() - self.view_offset + y
}
/// Cells of an absolute row (scrollback first, then the live screen).
fn abs_row(&self, row: usize) -> &[Cell] {
if row < self.scrollback.len() {
&self.scrollback[row]
} else {
&self.lines[row - self.scrollback.len()]
}
}
/// Slide an active selection up by `n` rows after scrollback eviction,
/// dropping it if either endpoint scrolled off the top.
fn shift_selection(&mut self, n: usize) {
if let Some((a, b)) = self.selection {
if a.row < n || b.row < n {
self.selection = None;
} else {
self.selection = Some((
Point {
row: a.row - n,
..a
},
Point {
row: b.row - n,
..b
},
));
}
}
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
/// Begin a selection at an absolute point (drag anchor).
pub fn start_selection(&mut self, row: usize, col: usize) {
let p = Point { row, col };
self.selection = Some((p, p));
}
/// Move the selection head (drag), keeping the anchor fixed.
pub fn extend_selection(&mut self, row: usize, col: usize) {
if let Some((_, head)) = self.selection.as_mut() {
*head = Point { row, col };
}
}
/// Select the whitespace-delimited word at an absolute point.
pub fn select_word(&mut self, row: usize, col: usize) {
let line = self.abs_row(row);
let word = |c: char| !c.is_whitespace();
if col >= line.len() || !word(line[col].c) {
self.start_selection(row, col);
return;
}
let mut lo = col;
while lo > 0 && word(line[lo - 1].c) {
lo -= 1;
}
let mut hi = col;
while hi + 1 < line.len() && word(line[hi + 1].c) {
hi += 1;
}
self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));
}
/// Select the whole line at an absolute row.
pub fn select_line(&mut self, row: usize) {
let last = self.abs_row(row).len().saturating_sub(1);
self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));
}
/// Normalized selection (start <= end in reading order), if any.
fn ordered_selection(&self) -> Option<(Point, Point)> {
self.selection.map(|(a, b)| {
if (a.row, a.col) <= (b.row, b.col) {
(a, b)
} else {
(b, a)
}
})
}
/// Whether the cell at an absolute `(row, col)` falls inside the selection.
pub fn is_selected(&self, row: usize, col: usize) -> bool {
let Some((start, end)) = self.ordered_selection() else {
return false;
};
if row < start.row || row > end.row {
return false;
}
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row { end.col } else { usize::MAX };
col >= lo && col <= hi
}
/// The selected text, with trailing blanks trimmed per line and rows joined
/// by newlines. `None` if there is no selection.
pub fn selection_text(&self) -> Option<String> {
let (start, end) = self.ordered_selection()?;
let mut out = String::new();
for row in start.row..=end.row {
let line = self.abs_row(row);
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row {
(end.col + 1).min(line.len())
} else {
line.len()
};
let text: String = line
.get(lo..hi)
.unwrap_or(&[])
.iter()
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
.map(|c| c.c)
.collect();
out.push_str(text.trim_end());
if row != end.row {
out.push('\n');
}
}
Some(out)
}
pub fn set_bracketed_paste(&mut self, on: bool) {
self.bracketed_paste = on;
}
pub fn bracketed_paste(&self) -> bool {
self.bracketed_paste
}
// --- inspection (logging + tests) --- // --- inspection (logging + tests) ---
/// The visible text of one row, trailing blanks trimmed. /// The visible text of one row, trailing blanks trimmed.
@ -811,6 +969,36 @@ mod tests {
assert_eq!(g.cell(2, 0).c, 'x'); assert_eq!(g.cell(2, 0).c, 'x');
} }
#[test]
fn selection_extracts_text_across_rows() {
let mut g = Grid::new(8, 2);
for c in "abcd".chars() {
g.print(c);
}
g.carriage_return();
g.line_feed();
for c in "efgh".chars() {
g.print(c);
}
// Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).
g.start_selection(0, 2);
g.extend_selection(1, 1);
assert!(g.is_selected(0, 3));
assert!(g.is_selected(1, 0));
assert!(!g.is_selected(1, 2));
assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));
}
#[test]
fn select_word_spans_one_word() {
let mut g = Grid::new(16, 1);
for c in "foo bar baz".chars() {
g.print(c);
}
g.select_word(0, 5); // inside "bar"
assert_eq!(g.selection_text().as_deref(), Some("bar"));
}
#[test] #[test]
fn scroll_region_limits_line_feed() { fn scroll_region_limits_line_feed() {
let mut g = Grid::new(4, 4); let mut g = Grid::new(4, 4);

View file

@ -11,6 +11,8 @@ use crate::grid::{Cell, Color, CursorShape, Flags, Grid, Underline};
/// Foreground/background used for `Color::Default`. /// Foreground/background used for `Color::Default`.
const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6); const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);
const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18); const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);
/// Background painted behind selected cells.
const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
struct Rgb(u8, u8, u8); struct Rgb(u8, u8, u8);
@ -132,8 +134,13 @@ impl Renderer {
// default - most of a screen is default background. Rows come through // default - most of a screen is default background. Rows come through
// the scrollback viewport and may be shorter than `cols` after a resize. // the scrollback viewport and may be shorter than `cols` after a resize.
for y in 0..grid.rows() { for y in 0..grid.rows() {
let abs = grid.view_to_abs(y);
for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() { for (x, cell) in grid.view_row(y).iter().take(cols).enumerate() {
let (_, bg) = cell_colors(cell); let bg = if grid.is_selected(abs, x) {
SELECTION_BG
} else {
cell_colors(cell).1
};
if bg != DEFAULT_BG { if bg != DEFAULT_BG {
let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32); let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32);
canvas.fill_rect(px, py, m.width, m.height, bg); canvas.fill_rect(px, py, m.width, m.height, bg);

View file

@ -119,6 +119,10 @@ impl Term {
&self.grid &self.grid
} }
pub fn grid_mut(&mut self) -> &mut Grid {
&mut self.grid
}
pub fn resize(&mut self, cols: usize, rows: usize) { pub fn resize(&mut self, cols: usize, rows: usize) {
self.grid.resize(cols, rows); self.grid.resize(cols, rows);
} }
@ -175,6 +179,7 @@ impl Term {
(false, 4) => self.grid.set_insert(on), (false, 4) => self.grid.set_insert(on),
(true, 1) => self.grid.set_app_cursor(on), (true, 1) => self.grid.set_app_cursor(on),
(true, 25) => self.grid.set_cursor_visible(on), (true, 25) => self.grid.set_cursor_visible(on),
(true, 2004) => self.grid.set_bracketed_paste(on),
// App-cursor/bracketed-paste/mouse/sync modes affect input and // App-cursor/bracketed-paste/mouse/sync modes affect input and
// rendering, which arrive with the keyboard and renderer. // rendering, which arrive with the keyboard and renderer.
_ => tracing::trace!("unhandled mode {code} private={private} on={on}"), _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),

View file

@ -3,6 +3,8 @@
//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the //! Uses smithay-client-toolkit for protocol boilerplate and calloop for the
//! event loop, so the PTY master fd and timers share one loop. //! 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::fd::OwnedFd;
use std::os::unix::process::ExitStatusExt; use std::os::unix::process::ExitStatusExt;
use std::process::ExitCode; use std::process::ExitCode;
@ -16,17 +18,33 @@ use crate::font::Fonts;
use crate::pty::Pty; use crate::pty::Pty;
use crate::render::Renderer; use crate::render::Renderer;
use crate::vt::Term; 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::{ use smithay_client_toolkit::{
compositor::{CompositorHandler, CompositorState}, compositor::{CompositorHandler, CompositorState},
delegate_compositor, delegate_keyboard, delegate_output, delegate_pointer, delegate_registry, data_device_manager::{
delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window, 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}, output::{OutputHandler, OutputState},
primary_selection::{
PrimarySelectionManagerState,
device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},
selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},
},
registry::{ProvidesRegistryState, RegistryState}, registry::{ProvidesRegistryState, RegistryState},
registry_handlers, registry_handlers,
seat::{ seat::{
Capability, SeatHandler, SeatState, Capability, SeatHandler, SeatState,
keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo}, keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},
pointer::{PointerEvent, PointerEventKind, PointerHandler}, pointer::{BTN_LEFT, BTN_MIDDLE, PointerEvent, PointerEventKind, PointerHandler},
}, },
shell::{ shell::{
WaylandSurface, WaylandSurface,
@ -40,9 +58,34 @@ use smithay_client_toolkit::{
use wayland_client::{ use wayland_client::{
Connection, QueueHandle, Connection, QueueHandle,
globals::registry_queue_init, 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. /// Default window size in pixels before the compositor suggests one.
const DEFAULT_W: u32 = 800; const DEFAULT_W: u32 = 800;
const DEFAULT_H: u32 = 600; 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 compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;
let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base 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 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 surface = compositor.create_surface(&qh);
let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh); let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);
@ -90,6 +136,19 @@ pub fn run() -> anyhow::Result<ExitCode> {
window, window,
renderer, renderer,
loop_handle: event_loop.handle(), 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, keyboard: None,
pointer: None, pointer: None,
modifiers: Modifiers::default(), modifiers: Modifiers::default(),
@ -159,6 +218,23 @@ struct App {
window: Window, window: Window,
renderer: Renderer, renderer: Renderer,
loop_handle: LoopHandle<'static, App>, 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>, keyboard: Option<wl_keyboard::WlKeyboard>,
pointer: Option<wl_pointer::WlPointer>, pointer: Option<wl_pointer::WlPointer>,
modifiers: Modifiers, modifiers: Modifiers,
@ -240,6 +316,22 @@ impl App {
/// viewport locally; anything else is encoded to the shell and snaps the /// viewport locally; anything else is encoded to the shell and snaps the
/// viewport back to the live screen. /// viewport back to the live screen.
fn handle_key(&mut self, event: &KeyEvent) { 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 self.modifiers.shift && matches!(event.keysym, Keysym::Page_Up | Keysym::Page_Down) {
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
let page = session.term.page() as isize; let page = session.term.page() as isize;
@ -262,6 +354,7 @@ impl App {
&& let Some(session) = self.session.as_mut() && let Some(session) = self.session.as_mut()
{ {
session.term.scroll_to_bottom(); session.term.scroll_to_bottom();
session.term.grid_mut().clear_selection();
self.dirty = true; self.dirty = true;
if let Err(err) = write_all(session.pty.master(), &bytes) { if let Err(err) = write_all(session.pty.master(), &bytes) {
tracing::warn!("write key to pty: {err}"); 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. /// After parsing child output: send any replies, sync the title, repaint.
fn after_feed(&mut self) { fn after_feed(&mut self) {
let Some(session) = self.session.as_mut() else { let Some(session) = self.session.as_mut() else {
@ -452,6 +740,15 @@ impl SeatHandler for App {
seat: wl_seat::WlSeat, seat: wl_seat::WlSeat,
capability: Capability, 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() { if capability == Capability::Keyboard && self.keyboard.is_none() {
// get_keyboard_with_repeat drives key repeat off a calloop timer and // get_keyboard_with_repeat drives key repeat off a calloop timer and
// delivers each repeat through the callback. // delivers each repeat through the callback.
@ -508,10 +805,11 @@ impl KeyboardHandler for App {
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard, _: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface, _: &wl_surface::WlSurface,
_: u32, serial: u32,
_: &[u32], _: &[u32],
_: &[Keysym], _: &[Keysym],
) { ) {
self.serial = serial;
self.focused = true; self.focused = true;
self.dirty = true; self.dirty = true;
} }
@ -533,9 +831,10 @@ impl KeyboardHandler for App {
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard, _: &wl_keyboard::WlKeyboard,
_: u32, serial: u32,
event: KeyEvent, event: KeyEvent,
) { ) {
self.serial = serial;
self.handle_key(&event); self.handle_key(&event);
} }
@ -588,17 +887,37 @@ impl PointerHandler for App {
fn pointer_frame( fn pointer_frame(
&mut self, &mut self,
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, qh: &QueueHandle<Self>,
_: &wl_pointer::WlPointer, _: &wl_pointer::WlPointer,
events: &[PointerEvent], events: &[PointerEvent],
) { ) {
let cell_h = f64::from(self.renderer.metrics().height); let cell_h = f64::from(self.renderer.metrics().height);
for event in events { for event in events {
let PointerEventKind::Axis { vertical, .. } = &event.kind else { match &event.kind {
continue; PointerEventKind::Enter { .. } | PointerEventKind::Motion { .. } => {
}; self.pointer_pos = event.position;
// Wheel notches arrive as value120 (÷120) or legacy discrete steps, self.pointer_drag();
// ~3 lines each; touchpads send pixels, mapped per cell height. }
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 { let (raw, scale) = if vertical.value120 != 0 {
(f64::from(vertical.value120) / 120.0, 3.0) (f64::from(vertical.value120) / 120.0, 3.0)
} else if vertical.discrete != 0 { } else if vertical.discrete != 0 {
@ -611,8 +930,8 @@ impl PointerHandler for App {
if raw == 0.0 { if raw == 0.0 {
continue; continue;
} }
// Positive axis = scroll down (toward live); we scroll the viewport // Positive axis = scroll down (toward live); the viewport
// the opposite way (negative offset delta). // scrolls the opposite way (negative offset delta).
let lines = (raw.abs() * scale).ceil().max(1.0) as isize; let lines = (raw.abs() * scale).ceil().max(1.0) as isize;
let delta = if raw < 0.0 { lines } else { -lines }; let delta = if raw < 0.0 { lines } else { -lines };
if let Some(session) = self.session.as_mut() { if let Some(session) = self.session.as_mut() {
@ -620,6 +939,9 @@ impl PointerHandler for App {
self.dirty = true; self.dirty = true;
} }
} }
_ => {}
}
}
} }
} }
@ -642,6 +964,134 @@ impl ProvidesRegistryState for App {
registry_handlers![OutputState, SeatState]; 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_compositor!(App);
delegate_output!(App); delegate_output!(App);
delegate_shm!(App); delegate_shm!(App);
@ -650,4 +1100,6 @@ delegate_keyboard!(App);
delegate_pointer!(App); delegate_pointer!(App);
delegate_xdg_shell!(App); delegate_xdg_shell!(App);
delegate_xdg_window!(App); delegate_xdg_window!(App);
delegate_data_device!(App);
delegate_primary_selection!(App);
delegate_registry!(App); delegate_registry!(App);