brewery
notashelf /
78874201392c1400b54981ab911f43ad3a7d974b

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 78874201392c

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified4 files changed+684-32
render: mouse selection with clipboard and primary copy-paste

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I808839078ae2674caa1f1bfd7e84f3bc6a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex 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<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);diff --git a/src/render.rs b/src/render.rsindex 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.rsindex 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.rsindex 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<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);