diff --git a/src/grid.rs b/src/grid.rs index fdd3703..67c9e4e 100644 --- a/src/grid.rs +++ b/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 { @@ -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 { + 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); diff --git a/src/render.rs b/src/render.rs index b3ca7a7..697bcae 100644 --- a/src/render.rs +++ b/src/render.rs @@ -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); diff --git a/src/vt.rs b/src/vt.rs index b698824..39514f0 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -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}"), diff --git a/src/wayland.rs b/src/wayland.rs index afc9e86..e00a920 100644 --- a/src/wayland.rs +++ b/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 { + 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 { 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 { 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, + data_device_manager: DataDeviceManagerState, + primary_manager: Option, + data_device: Option, + primary_device: Option, + /// Held while we own the clipboard / primary selection, serving paste reads. + copy_source: Option, + primary_source: Option, + 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, pointer: Option, 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) { + 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 { + 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) { + 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) { + 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 = 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 = 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, _: &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, _: &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, + qh: &QueueHandle, _: &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, + _: &WlDataDevice, + _: f64, + _: f64, + _: &wl_surface::WlSurface, + ) { + } + fn leave(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} + fn motion(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice, _: f64, _: f64) {} + fn selection(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} + fn drop_performed(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} +} + +impl DataOfferHandler for App { + fn source_actions( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &mut DragOffer, + _: DndAction, + ) { + } + fn selected_action( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &mut DragOffer, + _: DndAction, + ) { + } +} + +impl DataSourceHandler for App { + fn accept_mime( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &WlDataSource, + _: Option, + ) { + } + + fn send_request( + &mut self, + _: &Connection, + _: &QueueHandle, + 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, 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, _: &WlDataSource) {} + fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource) {} + fn action(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource, _: DndAction) {} +} + +impl PrimarySelectionDeviceHandler for App { + fn selection( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &ZwpPrimarySelectionDeviceV1, + ) { + } +} + +impl PrimarySelectionSourceHandler for App { + fn send_request( + &mut self, + _: &Connection, + _: &QueueHandle, + 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, + 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);