clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 78874201392c
tarballunverified4 files changed+684-32
@@ -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.@@ -812,6 +970,36 @@ mod tests { } #[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); g.set_scroll_region(1, 2);@@ -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}"),@@ -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);