forked from NotAShelf/beer
render: mouse selection with clipboard and primary copy-paste
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I808839078ae2674caa1f1bfd7e84f3bc6a6a6964
This commit is contained in:
parent
f52af55f66
commit
7887420139
4 changed files with 684 additions and 32 deletions
188
src/grid.rs
188
src/grid.rs
|
|
@ -106,6 +106,14 @@ struct Cursor {
|
|||
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.
|
||||
#[derive(Debug)]
|
||||
pub struct Grid {
|
||||
|
|
@ -137,6 +145,10 @@ pub struct Grid {
|
|||
app_cursor: bool,
|
||||
/// Cursor colour from OSC 12; `None` follows the cell under the cursor.
|
||||
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> {
|
||||
|
|
@ -168,6 +180,8 @@ impl Grid {
|
|||
cursor_visible: true,
|
||||
cursor_color: None,
|
||||
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]);
|
||||
self.scrollback.push_back(line);
|
||||
}
|
||||
let mut evicted = 0;
|
||||
while self.scrollback.len() > SCROLLBACK_CAP {
|
||||
self.scrollback.pop_front();
|
||||
evicted += 1;
|
||||
}
|
||||
if evicted > 0 {
|
||||
self.shift_selection(evicted);
|
||||
}
|
||||
// Keep a scrolled-back viewport anchored to the same content.
|
||||
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) ---
|
||||
|
||||
/// The visible text of one row, trailing blanks trimmed.
|
||||
|
|
@ -811,6 +969,36 @@ mod tests {
|
|||
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]
|
||||
fn scroll_region_limits_line_feed() {
|
||||
let mut g = Grid::new(4, 4);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ use crate::grid::{Cell, Color, CursorShape, Flags, Grid, Underline};
|
|||
/// Foreground/background used for `Color::Default`.
|
||||
const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);
|
||||
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)]
|
||||
struct Rgb(u8, u8, u8);
|
||||
|
|
@ -132,8 +134,13 @@ impl Renderer {
|
|||
// default - most of a screen is default background. Rows come through
|
||||
// the scrollback viewport and may be shorter than `cols` after a resize.
|
||||
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() {
|
||||
let (_, bg) = cell_colors(cell);
|
||||
let bg = if grid.is_selected(abs, x) {
|
||||
SELECTION_BG
|
||||
} else {
|
||||
cell_colors(cell).1
|
||||
};
|
||||
if bg != DEFAULT_BG {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,10 @@ impl Term {
|
|||
&self.grid
|
||||
}
|
||||
|
||||
pub fn grid_mut(&mut self) -> &mut Grid {
|
||||
&mut self.grid
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: usize, rows: usize) {
|
||||
self.grid.resize(cols, rows);
|
||||
}
|
||||
|
|
@ -175,6 +179,7 @@ impl Term {
|
|||
(false, 4) => self.grid.set_insert(on),
|
||||
(true, 1) => self.grid.set_app_cursor(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
|
||||
// rendering, which arrive with the keyboard and renderer.
|
||||
_ => tracing::trace!("unhandled mode {code} private={private} on={on}"),
|
||||
|
|
|
|||
480
src/wayland.rs
480
src/wayland.rs
|
|
@ -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,17 +887,37 @@ 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.
|
||||
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 {
|
||||
|
|
@ -611,8 +930,8 @@ impl PointerHandler for App {
|
|||
if raw == 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Positive axis = scroll down (toward live); we scroll the viewport
|
||||
// the opposite way (negative offset delta).
|
||||
// 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() {
|
||||
|
|
@ -620,6 +939,9 @@ impl PointerHandler for App {
|
|||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue