Skip to main content

max / shop

Add mouse selection, clipboard, and primary selection Selection is a gesture (anchor, head, granularity) resolved against grid contents into the cells it covers, so the binary can carry one across events without borrowing the grid. Char, word, line and block, by click count and Ctrl. Drawn as one quad per row, above the cells' own backgrounds and below the glyphs, in a new theme intent at an alpha shop owns rather than the palette. Two selection registers, kept apart as X has always had them: finishing a drag fills the primary selection, Ctrl+Shift+C fills the clipboard. Collapsing them would mean selecting text destroys what was last copied deliberately. Paste reads asynchronously through calloop, which is not optional -- pasting our own clipboard would otherwise deadlock, since the compositor answers by asking us for the data on a dispatch we would be blocked inside. Pasted text has its newlines folded to carriage returns and any embedded bracketed-paste terminator removed, whether or not the mode is on, so text carrying the end marker cannot close the bracket early and arrive as if typed. Copying a wrapped line needed the grid to know it was wrapped, so rows now carry a continuation flag, indexed physically to ride the scroll ring for free. Without it a long command pastes back as two lines and half of it executes on its own. Fixes erase-in-line addressing a row physically rather than through the ring, which erased the wrong line after any scroll.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 18:48 UTC
Signed with PGP, not checked
Commit: 20c56682cdd16f32506d2c8a90570719767befa9
Parent: 47c9ef3
5 files changed, +1271 insertions, -30 deletions
@@ -7,6 +7,9 @@
7 7 //! Implements [`vte::Perform`], so the binary can pipe PTY bytes through a
8 8 //! `vte::Parser` straight into the grid.
9 9
10 + mod selection;
11 + pub use selection::{Point, Selection, SelectionMode, SelectionSpan};
12 +
10 13 use shop_vt::{Params, Perform};
11 14 use tracing::trace;
12 15
@@ -233,6 +236,16 @@
233 236 // Partial-region scrolls fall back to memcpy through ring-mapped indices.
234 237 main: Vec<Cell>,
235 238 alt: Vec<Cell>,
239 + // One flag per PHYSICAL row: "this row ran off the right edge and
240 + // continues on the next one". Physical indexing is what makes the ring
241 + // scroll carry the flags for free — an origin bump moves rows and their
242 + // wrap state together, with no second pass.
243 + //
244 + // Only the deferred wrap in `place_char` sets a flag. A row that happens
245 + // to fill exactly and is then ended with CR/LF is not wrapped, which is
246 + // the distinction that decides whether copied text gets a newline here.
247 + main_wrapped: Vec<bool>,
248 + alt_wrapped: Vec<bool>,
236 249 on_alt: bool,
237 250 main_origin: u16,
238 251 alt_origin: u16,
@@ -264,6 +277,10 @@
264 277 // reflected via [`Grid::sync_update`] — the binary decides whether/how
265 278 // long to defer redraws (typical timeout is ~150ms).
266 279 sync_update: bool,
280 + // DECSET 2004: the program has asked to be told that text arrived by
281 + // paste rather than by typing, so it can decline to act on it. Reflected
282 + // via [`Grid::bracketed_paste`]; wrapping the payload is the binary's job.
283 + bracketed_paste: bool,
267 284 pending_title: Option<String>,
268 285 // Damage tracking — accumulated between take_damage() calls.
269 286 row_dirty: Vec<bool>,
@@ -284,6 +301,8 @@
284 301 rows,
285 302 main: vec![Cell::default(); cell_count],
286 303 alt: vec![Cell::default(); cell_count],
304 + main_wrapped: vec![false; rows as usize],
305 + alt_wrapped: vec![false; rows as usize],
287 306 on_alt: false,
288 307 main_origin: 0,
289 308 alt_origin: 0,
@@ -304,6 +323,7 @@
304 323 pending_bg_word: 0,
305 324 cur_row_start: CUR_ROW_INVALID,
306 325 sync_update: false,
326 + bracketed_paste: false,
307 327 pending_title: None,
308 328 // Initial state: everything dirty so first render populates the
309 329 // per-row cache.
@@ -359,6 +379,16 @@
359 379 self.sync_update
360 380 }
361 381
382 + /// True while the program has DECSET 2004 on and wants pasted text
383 + /// wrapped in `\e[200~` / `\e[201~`.
384 + ///
385 + /// A shell that knows the difference will not run a pasted command until
386 + /// the user presses Enter, which is the whole point of the mode: pasting
387 + /// something with a newline in it stops being an accidental execution.
388 + pub fn bracketed_paste(&self) -> bool {
389 + self.bracketed_paste
390 + }
391 +
362 392 /// Consume any window title set by the shell via OSC 0/2 since the last
363 393 /// call. Binary polls after each `parser.advance` and forwards to
364 394 /// `xdg_window.set_title`.
@@ -404,8 +434,8 @@
404 434 }
405 435 }
406 436
407 - /// Physical byte offset for the start of logical row `r`.
408 - fn row_start(&self, r: u16) -> usize {
437 + /// Physical row index backing logical row `r`.
438 + fn phys_row(&self, r: u16) -> u16 {
409 439 let phys = if self.is_partial_region() && r >= self.scroll_top && r <= self.scroll_bottom {
410 440 // In partial region: active_origin is guaranteed 0 by unroll on
411 441 // transition, so we rotate only within the region.
@@ -416,7 +446,42 @@
416 446 } else {
417 447 (self.active_origin() as u32 + r as u32) % self.rows as u32
418 448 };
419 - phys as usize * self.cols as usize
449 + phys as u16
450 + }
451 +
452 + /// Physical byte offset for the start of logical row `r`.
453 + fn row_start(&self, r: u16) -> usize {
454 + self.phys_row(r) as usize * self.cols as usize
455 + }
456 +
457 + fn active_wrapped_mut(&mut self) -> &mut [bool] {
458 + if self.on_alt {
459 + &mut self.alt_wrapped
460 + } else {
461 + &mut self.main_wrapped
462 + }
463 + }
464 +
465 + /// Does logical row `r` continue onto row `r + 1`?
466 + ///
467 + /// True only when the shell's output ran off the right edge, so a copy
468 + /// spanning the two rows should join them without a newline. A row that
469 + /// filled exactly and then got an explicit CR/LF reads false.
470 + pub fn row_wrapped(&self, r: u16) -> bool {
471 + let phys = self.phys_row(r) as usize;
472 + let flags = if self.on_alt {
473 + &self.alt_wrapped
474 + } else {
475 + &self.main_wrapped
476 + };
477 + flags.get(phys).copied().unwrap_or(false)
478 + }
479 +
480 + fn set_row_wrapped(&mut self, r: u16, wrapped: bool) {
481 + let phys = self.phys_row(r) as usize;
482 + if let Some(slot) = self.active_wrapped_mut().get_mut(phys) {
483 + *slot = wrapped;
484 + }
420 485 }
421 486
422 487 fn is_partial_region(&self) -> bool {
@@ -434,6 +499,7 @@
434 499 let cols = self.cols as usize;
435 500 let cells = self.active_cells_mut();
436 501 cells.rotate_left(origin as usize * cols);
502 + self.active_wrapped_mut().rotate_left(origin as usize);
437 503 if self.on_alt {
438 504 self.alt_origin = 0;
439 505 } else {
@@ -450,10 +516,12 @@
450 516 }
451 517 let cols = self.cols as usize;
452 518 let top = self.scroll_top as usize;
453 - let region_len = (self.scroll_bottom - self.scroll_top + 1) as usize * cols;
454 - let shift = self.region_origin as usize * cols;
519 + let region_rows = (self.scroll_bottom - self.scroll_top + 1) as usize;
520 + let region_len = region_rows * cols;
521 + let shift = self.region_origin as usize;
455 522 let cells = self.active_cells_mut();
456 - cells[top * cols..top * cols + region_len].rotate_left(shift);
523 + cells[top * cols..top * cols + region_len].rotate_left(shift * cols);
524 + self.active_wrapped_mut()[top..top + region_rows].rotate_left(shift);
457 525 self.region_origin = 0;
458 526 }
459 527
@@ -479,6 +547,9 @@
479 547 for cell in &mut cells[start..start + cols] {
480 548 *cell = Cell::default();
481 549 }
550 + if let Some(slot) = self.active_wrapped_mut().get_mut(phys as usize) {
551 + *slot = false;
552 + }
482 553 }
483 554
484 555 /// Zero one logical row's cells.
@@ -489,6 +560,7 @@
489 560 for cell in &mut cells[start..start + cols] {
490 561 *cell = Cell::default();
491 562 }
563 + self.set_row_wrapped(r, false);
492 564 }
493 565
494 566 /// Resize the grid, preserving as much of the top-left as fits. Truncates
@@ -508,6 +580,11 @@
508 580 rows,
509 581 );
510 582 self.alt = resize_buf(&self.alt, self.alt_origin, self.cols, self.rows, cols, rows);
583 + // Resize does not reflow, so every recorded wrap point is now a lie
584 + // about where the text actually runs off the edge. Drop them all
585 + // rather than carry wrong ones into a copy.
586 + self.main_wrapped = vec![false; rows as usize];
587 + self.alt_wrapped = vec![false; rows as usize];
511 588 self.main_origin = 0;
512 589 self.alt_origin = 0;
513 590 self.region_origin = 0;
@@ -540,6 +617,9 @@
540 617 // Deferred wrap: if the previous print landed on the rightmost cell,
541 618 // the next visible char starts a new line.
542 619 if self.cursor.wrap_next {
620 + // Record the continuation before newline() moves us off the row
621 + // (or scrolls the ring out from under it).
622 + self.set_row_wrapped(self.cursor.row, true);
543 623 self.newline();
544 624 self.cursor.col = 0;
545 625 self.cursor.wrap_next = false;
@@ -811,19 +891,14 @@
811 891 }
812 892
813 893 fn erase_line(&mut self, mode: u16) {
814 - let cols = self.cols as usize;
815 894 let row = self.cursor.row;
816 - let row_start = row as usize * cols;
817 - let col = self.cursor.col as usize;
818 - let cells = self.active_cells_mut();
819 - let (from, to) = match mode {
820 - 1 => (row_start, row_start + col + 1), // start to cursor
821 - 2 => (row_start, row_start + cols), // whole line
822 - _ => (row_start + col, row_start + cols), // 0: cursor to end
895 + let col = self.cursor.col;
896 + let (start_col, end_col) = match mode {
897 + 1 => (0, col + 1), // start to cursor
898 + 2 => (0, self.cols), // whole line
899 + _ => (col, self.cols), // 0: cursor to end
823 900 };
824 - for cell in &mut cells[from..to] {
825 - *cell = Cell::default();
826 - }
901 + self.erase_line_range(row, start_col, end_col);
827 902 self.mark_row_dirty(row);
828 903 }
829 904
@@ -858,12 +933,17 @@
858 933
859 934 fn erase_line_range(&mut self, row: u16, start_col: u16, end_col: u16) {
860 935 let row_start = self.row_start(row);
861 - let end_col = end_col.min(self.cols) as usize;
862 - let start_col = start_col.min(end_col as u16) as usize;
936 + let end_col = end_col.min(self.cols);
937 + let start_col = start_col.min(end_col);
863 938 let cells = self.active_cells_mut();
864 - for cell in &mut cells[row_start + start_col..row_start + end_col] {
939 + for cell in &mut cells[row_start + start_col as usize..row_start + end_col as usize] {
865 940 *cell = Cell::default();
866 941 }
942 + // Erasing through the right edge destroys whatever ran off it, so the
943 + // row no longer continues onto the next.
944 + if end_col == self.cols {
945 + self.set_row_wrapped(row, false);
946 + }
867 947 }
868 948
869 949 fn swap_alt(&mut self, to_alt: bool) {
@@ -879,6 +959,7 @@
879 959 for cell in &mut self.alt {
880 960 *cell = Cell::default();
881 961 }
962 + self.alt_wrapped.fill(false);
882 963 self.cursor = self.saved_alt_cursor;
883 964 } else {
884 965 self.saved_alt_cursor = self.cursor;
@@ -1150,6 +1231,7 @@
1150 1231 // until `l` or the caller's timeout); `l` ends
1151 1232 // it. Grid just tracks the state — the binary
1152 1233 // is what actually defers the redraw.
1234 + 2004 => self.bracketed_paste = action == 'h',
1153 1235 2026 => self.sync_update = action == 'h',
1154 1236 _ => {}
1155 1237 }
@@ -1540,4 +1622,117 @@
1540 1622 let c = g.cursor();
1541 1623 assert!(c.row < 2 && c.col < 4);
1542 1624 }
1625 +
1626 + #[test]
1627 + fn bracketed_paste_toggles_on_2004() {
1628 + let mut g = Grid::new(10, 3);
1629 + assert!(!g.bracketed_paste(), "off until a program asks");
1630 + feed(&mut g, b"\x1b[?2004h");
1631 + assert!(g.bracketed_paste());
1632 + feed(&mut g, b"\x1b[?2004l");
1633 + assert!(!g.bracketed_paste());
1634 + }
1635 +
1636 + #[test]
1637 + fn bracketed_paste_survives_an_alt_screen_round_trip() {
1638 + // vim sets it, and leaving the alt screen is not the shell revoking
1639 + // it — the shell set its own before vim ever started.
1640 + let mut g = Grid::new(10, 3);
1641 + feed(&mut g, b"\x1b[?2004h");
1642 + feed(&mut g, b"\x1b[?1049h");
1643 + feed(&mut g, b"\x1b[?1049l");
1644 + assert!(g.bracketed_paste());
1645 + }
1646 +
1647 + // ---- wrapped-row flag ----------------------------------------------
1648 +
1649 + #[test]
1650 + fn deferred_wrap_marks_the_row_it_left() {
1651 + let mut g = Grid::new(4, 3);
1652 + feed(&mut g, b"abcdef");
1653 + assert!(g.row_wrapped(0));
1654 + assert!(!g.row_wrapped(1));
1655 + }
1656 +
1657 + #[test]
1658 + fn filling_a_row_exactly_does_not_mark_it_wrapped() {
1659 + let mut g = Grid::new(4, 3);
1660 + feed(&mut g, b"abcd");
1661 + assert!(!g.row_wrapped(0), "wrap_next alone is not a wrap");
1662 + feed(&mut g, b"\r\nefgh");
1663 + assert!(!g.row_wrapped(0));
1664 + }
1665 +
1666 + #[test]
1667 + fn the_wrapped_flag_rides_the_scroll_ring() {
1668 + let mut g = Grid::new(4, 3);
1669 + // "abcd" wraps onto "ef", one row down from the top.
1670 + feed(&mut g, b"xy\r\nabcdef");
1671 + assert!(g.row_wrapped(1));
1672 + // Scrolling carries the wrapped row up to row 0 — the flag is indexed
1673 + // physically, so it has to arrive with it.
1674 + feed(&mut g, b"\r\n");
1675 + assert_eq!(row_str(&g, 0), "abcd");
1676 + assert!(g.row_wrapped(0));
1677 + // One more scroll and it leaves the screen entirely.
1678 + feed(&mut g, b"\r\n");
1679 + assert_eq!(row_str(&g, 0), "ef");
1680 + assert!(!g.row_wrapped(0), "the wrapped row scrolled off the top");
1681 + }
1682 +
1683 + #[test]
1684 + fn a_blank_row_exposed_by_a_scroll_is_not_wrapped() {
1685 + let mut g = Grid::new(4, 2);
1686 + feed(&mut g, b"abcdef\r\n\r\n\r\n");
1687 + for r in 0..g.rows() {
1688 + assert!(!g.row_wrapped(r), "row {r} came back wrapped");
1689 + }
1690 + }
1691 +
1692 + #[test]
1693 + fn resize_drops_every_wrap_point() {
1694 + let mut g = Grid::new(4, 3);
1695 + feed(&mut g, b"abcdef");
1696 + assert!(g.row_wrapped(0));
1697 + g.resize(8, 3);
1698 + assert!(!g.row_wrapped(0), "the wrap point is meaningless at 8 cols");
1699 + }
1700 +
1701 + #[test]
1702 + fn alt_screen_keeps_its_own_wrap_points() {
1703 + let mut g = Grid::new(4, 3);
1704 + feed(&mut g, b"abcdef");
1705 + feed(&mut g, b"\x1b[?1049h");
1706 + assert!(!g.row_wrapped(0), "alt screen starts clean");
1707 + feed(&mut g, b"\x1b[?1049l");
1708 + assert!(g.row_wrapped(0), "main screen's wrap point survived");
1709 + }
1710 +
1711 + // ---- erase against a rotated ring ----------------------------------
1712 +
1713 + #[test]
1714 + fn erase_line_targets_the_right_row_after_a_scroll() {
1715 + // Scroll far enough that the ring origin is non-zero, then erase the
1716 + // cursor's line. Erasing by logical row without the ring mapping
1717 + // would blank some other row entirely.
1718 + let mut g = Grid::new(6, 3);
1719 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
1720 + assert_eq!(row_str(&g, 0), "three");
1721 + assert_eq!(row_str(&g, 1), "four");
1722 + assert_eq!(row_str(&g, 2), "five");
1723 + feed(&mut g, b"\x1b[2;1H\x1b[2K"); // row 1, erase whole line
1724 + assert_eq!(row_str(&g, 0), "three");
1725 + assert_eq!(row_str(&g, 1), "");
1726 + assert_eq!(row_str(&g, 2), "five");
1727 + }
1728 +
1729 + #[test]
1730 + fn erase_to_end_of_line_targets_the_right_row_after_a_scroll() {
1731 + let mut g = Grid::new(6, 3);
1732 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
1733 + feed(&mut g, b"\x1b[3;3H\x1b[K"); // row 2 col 2, erase to end
1734 + assert_eq!(row_str(&g, 0), "three");
1735 + assert_eq!(row_str(&g, 1), "four");
1736 + assert_eq!(row_str(&g, 2), "fi");
1737 + }
1543 1738 }
@@ -9,14 +9,16 @@
9 9 use std::os::fd::{AsFd, AsRawFd};
10 10 use std::sync::Arc;
11 11
12 + mod clipboard;
12 13 mod theme;
14 + use clipboard::{MIME_UTF8, OFFERED_MIMES, sanitize_paste};
13 15 use theme::{Config, Palette};
14 16
15 - use calloop::EventLoop;
16 17 use calloop::generic::{FdWrapper, Generic};
18 + use calloop::{EventLoop, LoopHandle};
17 19 use calloop_wayland_source::WaylandSource;
18 20 use kittygfx as kgp;
19 - use shop_grid::{Color as GridColor, CursorShape, Grid};
21 + use shop_grid::{Color as GridColor, CursorShape, Grid, Point, Selection, SelectionMode};
20 22 use shop_pty::{Pty, PtySize};
21 23 use shop_render::{BgFill, ImagePlacement, ImageRenderer, TextRenderer};
22 24 use shop_wayland::{
@@ -25,14 +27,33 @@
25 27 WaylandChrome, WaylandSurface, WindowConfigure, WindowDecorations, WindowHandler, WindowSpec,
26 28 XdgShell, XdgWindow, registry_queue_init,
27 29 };
30 + use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{
31 + zwp_primary_selection_device_v1, zwp_primary_selection_source_v1,
32 + };
28 33 use smithay_client_toolkit::{
29 - delegate_dispatch2, delegate_registry, registry_handlers,
34 + data_device_manager::{
35 + DataDeviceManagerState, ReadPipe, WritePipe,
36 + data_device::{DataDevice, DataDeviceHandler},
37 + data_offer::{DataOfferHandler, DragOffer},
38 + data_source::{CopyPasteSource, DataSourceHandler},
39 + },
40 + delegate_dispatch2, delegate_registry,
41 + primary_selection::{
42 + PrimarySelectionManagerState,
43 + device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},
44 + selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},
45 + },
46 + registry_handlers,
30 47 seat::keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},
48 + seat::pointer::{PointerEvent, PointerEventKind, PointerHandler},
31 49 };
32 50 use std::collections::VecDeque;
51 + use std::io::{Read, Write};
33 52 use tracing::{info, warn};
34 53 use wayland_client::protocol::wl_keyboard;
35 - use wayland_client::protocol::{wl_callback, wl_output, wl_seat, wl_surface};
54 + use wayland_client::protocol::{
55 + wl_callback, wl_data_device, wl_data_source, wl_output, wl_pointer, wl_seat, wl_surface,
56 + };
36 57
37 58 const INITIAL: (u32, u32) = (960, 540);
38 59 /// IosevkaTerm Nerd Font Mono, terminal-optimized variant of Iosevka with
@@ -53,6 +74,23 @@
53 74 /// property of this terminal and not of the palette.
54 75 const CURSOR_ALPHA_ON: f32 = 0.85;
55 76 const CURSOR_ALPHA_DIM: f32 = 0.28;
77 + /// How much of a selected cell the wash covers.
78 + ///
79 + /// Shop's, not the palette's, for the same reason the cursor's alpha is: how
80 + /// heavily a terminal marks its selection is a property of the terminal. Low
81 + /// enough that syntax colours read through it, high enough to find the
82 + /// selection without looking for it.
83 + const SELECTION_ALPHA: f32 = 0.30;
84 + /// How long after a click a second one still counts as a double.
85 + ///
86 + /// The X11 default, and what GTK and Qt both still ship. Wayland exposes no
87 + /// double-click interval, so every client picks its own and they agree by
88 + /// convention rather than by protocol.
89 + const MULTI_CLICK_MS: u32 = 400;
90 +
91 + // Linux input event codes, as `wl_pointer.button` reports them.
92 + const BTN_LEFT: u32 = 0x110;
93 + const BTN_MIDDLE: u32 = 0x112;
56 94
57 95 fn main() -> anyhow::Result<()> {
58 96 tracing_subscriber::fmt()
@@ -185,6 +223,20 @@
185 223
186 224 let grid = Grid::new(cols_initial, rows_initial);
187 225 let parser = shop_vt::Parser::new();
226 +
227 + // Both selection protocols are optional. A compositor without them costs
228 + // shop copy and paste, not startup.
229 + let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)
230 + .inspect_err(|e| warn!("no wl_data_device_manager, clipboard disabled: {e}"))
231 + .ok();
232 + let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh)
233 + .inspect_err(|e| warn!("no primary selection, middle-click paste disabled: {e}"))
234 + .ok();
235 +
236 + // The loop is built before the app so the app can hold a handle to it.
237 + let mut event_loop: EventLoop<'static, App> = EventLoop::try_new()?;
238 + let loop_handle = event_loop.handle();
239 +
188 240 let mut app = App {
189 241 chrome,
190 242 surface,
@@ -204,7 +256,23 @@
204 256 font_data: FONT_BYTES.to_vec(),
205 257 scale: 1,
206 258 keyboard: None,
259 + pointer: None,
207 260 modifiers: Modifiers::default(),
261 + pointer_at: (0.0, 0.0),
262 + selection: None,
263 + dragging: false,
264 + last_click: None,
265 + click_count: 0,
266 + last_serial: 0,
267 + data_device_manager,
268 + primary_manager,
269 + data_device: None,
270 + primary_device: None,
271 + clipboard_source: None,
272 + clipboard_text: String::new(),
273 + primary_source: None,
274 + primary_text: String::new(),
275 + loop_handle: loop_handle.clone(),
208 276 cursor_phase: true,
209 277 focused: false,
210 278 qh: qh.clone(),
@@ -218,10 +286,7 @@
218 286 app.surface_config.height,
219 287 );
220 288
221 - // calloop event loop with wayland + PTY sources.
222 - let mut event_loop: EventLoop<App> = EventLoop::try_new()?;
223 - let loop_handle = event_loop.handle();
224 -
289 + // Wayland + PTY sources onto the loop built above.
225 290 WaylandSource::new(conn, event_queue)
226 291 .insert(loop_handle.clone())
227 292 .map_err(|e| anyhow::anyhow!("insert wayland source: {e}"))?;
@@ -478,6 +543,37 @@
478 543 }
479 544 }
480 545
546 + /// The cell under a surface-local position.
547 + ///
548 + /// Positions arrive in logical pixels, which is also what the padding and cell
549 + /// constants are in, so scale does not enter into this. Clamped rather than
550 + /// optional: a pointer out in the padding is treated as the nearest cell,
551 + /// which is what makes dragging off the edge of the window select to the end
552 + /// of the line instead of stopping dead.
553 + fn cell_at((x, y): (f64, f64), cols: u16, rows: u16) -> Point {
554 + let col = ((x - f64::from(PAD_X)) / f64::from(CELL_ADVANCE)).floor();
555 + let row = ((y - f64::from(PAD_Y)) / f64::from(CELL_HEIGHT)).floor();
556 + let to_index = |v: f64, count: u16| v.clamp(0.0, f64::from(count.saturating_sub(1))) as u16;
557 + Point::new(to_index(row, rows), to_index(col, cols))
558 + }
559 +
560 + /// Granularity counter for a press: 1 char, 2 word, 3 line.
561 + ///
562 + /// Climbs only for repeat presses in the same cell inside the double-click
563 + /// interval, and wraps, so a fourth click starts over at char granularity
564 + /// rather than sticking on whole lines.
565 + fn next_click_count(previous: Option<(u32, Point)>, count: u32, time: u32, at: Point) -> u32 {
566 + match previous {
567 + // wrapping_sub because the compositor's millisecond clock has an
568 + // arbitrary origin and is free to wrap; the difference stays right
569 + // across the wrap even though the operands don't.
570 + Some((last, cell)) if cell == at && time.wrapping_sub(last) < MULTI_CLICK_MS => {
571 + count % 3 + 1
572 + }
573 + _ => 1,
574 + }
575 + }
576 +
481 577 fn grid_cols(px_w: u32) -> u16 {
482 578 let usable = (px_w as f32 - 2.0 * PAD_X).max(CELL_ADVANCE);
483 579 (usable / CELL_ADVANCE) as u16
@@ -514,7 +610,41 @@
514 610 /// HiDPI integer scale from `wl_surface.enter` outputs.
515 611 scale: u32,
516 612 keyboard: Option<wl_keyboard::WlKeyboard>,
613 + pointer: Option<wl_pointer::WlPointer>,
517 614 modifiers: Modifiers,
615 + /// Pointer position in surface-local logical pixels, from the last event
616 + /// that carried one. Every event does, so this is always current.
617 + pointer_at: (f64, f64),
618 + /// The live selection, if any. Cleared when the grid changes under it in
619 + /// a way that would make it point at the wrong text.
620 + selection: Option<Selection>,
621 + /// Left button is down and the head is following the pointer.
622 + dragging: bool,
623 + /// Time and cell of the last left press, for deciding whether the next one
624 + /// is a double or triple click.
625 + last_click: Option<(u32, Point)>,
626 + /// 1, 2 or 3 — char, word or line granularity.
627 + click_count: u32,
628 + /// Serial of the most recent input event. A compositor will only hand out
629 + /// a selection on the strength of one, which is what stops a background
630 + /// window from silently taking the clipboard.
631 + last_serial: u32,
632 + /// Absent when the compositor does not advertise the global. Both are
633 + /// optional protocols and shop runs without either, minus the feature.
634 + data_device_manager: Option<DataDeviceManagerState>,
635 + primary_manager: Option<PrimarySelectionManagerState>,
636 + data_device: Option<DataDevice>,
637 + primary_device: Option<PrimarySelectionDevice>,
638 + /// Our offer of the clipboard, live only while we own it. The compositor
639 + /// cancels it when someone else copies, and dropping it withdraws it.
640 + clipboard_source: Option<CopyPasteSource>,
641 + clipboard_text: String,
642 + primary_source: Option<PrimarySelectionSource>,
643 + primary_text: String,
644 + /// Handle for registering paste pipes as event sources. Reading a paste
645 + /// has to be asynchronous: the source may be us, and a blocking read would
646 + /// then be waiting on a write we cannot make until we dispatch again.
647 + loop_handle: LoopHandle<'static, App>,
518 648 /// Cursor "activity light" phase. Toggles on every PTY-read chunk. Idle
519 649 /// prompt = steady on; heavy output = visible strobe.
520 650 cursor_phase: bool,
@@ -587,6 +717,7 @@
587 717 let pad_x_px = PAD_X * s;
588 718 let pad_y_px = PAD_Y * s;
589 719 let damage = app.grid.take_damage();
720 + app.apply_damage_to_selection(&damage);
590 721 app.text.ensure_rows(app.grid.rows());
591 722 if damage.screen_swapped || damage.resized {
592 723 app.text.clear_rows();
@@ -659,6 +790,27 @@
659 790 }
660 791 }
661 792
793 + // Selection sits above the cells' own backgrounds and below the glyphs —
794 + // one quad per row, not one per cell — so the text under it keeps the
795 + // colour the program asked for and reads through the wash.
796 + if let Some(sel) = &app.selection {
797 + let span = app.grid.selection_span(sel);
798 + let [r, g, b, _] = app.palette.selection;
799 + let color = [r, g, b, SELECTION_ALPHA];
800 + for row in span.start.row..=span.end.row {
801 + let Some((lo, hi)) = span.cols_on(row, app.grid.cols()) else {
802 + continue;
803 + };
804 + fills.push(BgFill {
805 + x: pad_x_px + lo as f32 * cell_w_px,
806 + y: pad_y_px + row as f32 * cell_h_px,
807 + w: f32::from(hi - lo + 1) * cell_w_px,
808 + h: cell_h_px,
809 + color,
810 + });
811 + }
812 + }
813 +
662 814 if cursor.visible {
663 815 let cx = pad_x_px + cursor.col as f32 * cell_w_px;
664 816 let cy = pad_y_px + cursor.row as f32 * cell_h_px;
@@ -794,7 +946,21 @@
794 946 fn seat_state(&mut self) -> &mut SeatState {
795 947 &mut self.chrome.seat
796 948 }
797 - fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
949 + fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
950 + // Both selections are per-seat, so the devices cannot exist before
951 + // one does. First seat wins: shop is one window with one focus, and a
952 + // second seat's clipboard is not a thing it has a way to show.
953 + if self.data_device.is_none()
954 + && let Some(mgr) = &self.data_device_manager
955 + {
956 + self.data_device = Some(mgr.get_data_device(qh, &seat));
957 + }
958 + if self.primary_device.is_none()
959 + && let Some(mgr) = &self.primary_manager
960 + {
961 + self.primary_device = Some(mgr.get_selection_device(qh, &seat));
962 + }
963 + }
798 964 fn new_capability(
799 965 &mut self,
800 966 _: &Connection,
@@ -808,6 +974,12 @@
808 974 Err(e) => warn!("get_keyboard: {e}"),
809 975 }
810 976 }
977 + if capability == Capability::Pointer && self.pointer.is_none() {
978 + match self.chrome.seat.get_pointer(qh, &seat) {
979 + Ok(ptr) => self.pointer = Some(ptr),
980 + Err(e) => warn!("get_pointer: {e}"),
981 + }
982 + }
811 983 }
812 984 fn remove_capability(
813 985 &mut self,
@@ -821,6 +993,14 @@
821 993 {
822 994 kb.release();
823 995 }
996 + if capability == Capability::Pointer
997 + && let Some(ptr) = self.pointer.take()
998 + {
999 + ptr.release();
1000 + // A pointer that goes away mid-drag leaves no way to finish the
1001 + // gesture, so end it where it stands rather than latching.
1002 + self.dragging = false;
1003 + }
824 1004 }
825 1005 fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
826 1006 }
@@ -855,9 +1035,10 @@
855 1035 _: &Connection,
856 1036 _: &QueueHandle<Self>,
857 1037 _: &wl_keyboard::WlKeyboard,
858 - _: u32,
1038 + serial: u32,
859 1039 event: KeyEvent,
860 1040 ) {
1041 + self.last_serial = serial;
861 1042 self.handle_key(&event);
862 1043 }
863 1044 fn repeat_key(
@@ -901,8 +1082,503 @@
901 1082 }
902 1083 }
903 1084
1085 + // -- Clipboard and primary selection ---------------------------------------
1086 + //
1087 + // Shop is a paste target and a copy source, and never a drag-and-drop one, so
1088 + // every DnD callback below is deliberately empty rather than unimplemented:
1089 + // the traits carry both jobs and we only do one of them.
1090 +
1091 + impl DataDeviceHandler for App {
1092 + fn enter(
1093 + &mut self,
1094 + _: &Connection,
1095 + _: &QueueHandle<Self>,
1096 + _: &wl_data_device::WlDataDevice,
1097 + _: f64,
1098 + _: f64,
1099 + _: &wl_surface::WlSurface,
1100 + ) {
1101 + }
1102 + fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_data_device::WlDataDevice) {}
1103 + fn motion(
1104 + &mut self,
1105 + _: &Connection,
1106 + _: &QueueHandle<Self>,
1107 + _: &wl_data_device::WlDataDevice,
1108 + _: f64,
1109 + _: f64,
1110 + ) {
1111 + }
1112 + fn selection(
1113 + &mut self,
1114 + _: &Connection,
1115 + _: &QueueHandle<Self>,
1116 + _: &wl_data_device::WlDataDevice,
1117 + ) {
1118 + // Someone else's clipboard is now the clipboard. Nothing to do until
1119 + // a paste asks for it — the offer is read from the device then, so
1120 + // holding onto it here would only risk using a stale one.
1121 + }
1122 + fn drop_performed(
1123 + &mut self,
1124 + _: &Connection,
1125 + _: &QueueHandle<Self>,
1126 + _: &wl_data_device::WlDataDevice,
1127 + ) {
1128 + }
1129 + }
1130 +
1131 + impl DataOfferHandler for App {
1132 + fn source_actions(
1133 + &mut self,
1134 + _: &Connection,
1135 + _: &QueueHandle<Self>,
1136 + _: &mut DragOffer,
1137 + _: smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction,
1138 + ) {
1139 + }
1140 + fn selected_action(
1141 + &mut self,
1142 + _: &Connection,
1143 + _: &QueueHandle<Self>,
1144 + _: &mut DragOffer,
1145 + _: smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction,
1146 + ) {
1147 + }
1148 + }
1149 +
1150 + impl DataSourceHandler for App {
1151 + fn send_request(
1152 + &mut self,
1153 + _: &Connection,
1154 + _: &QueueHandle<Self>,
1155 + source: &wl_data_source::WlDataSource,
1156 + _: String,
1157 + pipe: WritePipe,
1158 + ) {
1159 + // Only ever our clipboard source; anything else is a stale offer we
1160 + // already dropped, and answering it would send the wrong text.
1161 + if self.clipboard_source.as_ref().map(CopyPasteSource::inner) != Some(source) {
1162 + return;
1163 + }
1164 + write_selection(pipe, self.clipboard_text.as_bytes());
1165 + }
1166 +
1167 + fn cancelled(
1168 + &mut self,
1169 + _: &Connection,
1170 + _: &QueueHandle<Self>,
1171 + source: &wl_data_source::WlDataSource,
1172 + ) {
1173 + // We lost the clipboard to another client. Drop the source (which
1174 + // destroys it) and the text with it, so a later paste goes and asks
1175 + // the new owner instead of replaying what we used to hold.
1176 + if self.clipboard_source.as_ref().map(CopyPasteSource::inner) == Some(source) {
1177 + self.clipboard_source = None;
1178 + self.clipboard_text.clear();
1179 + }
1180 + }
1181 +
1182 + fn accept_mime(
1183 + &mut self,
1184 + _: &Connection,
1185 + _: &QueueHandle<Self>,
1186 + _: &wl_data_source::WlDataSource,
1187 + _: Option<String>,
1188 + ) {
1189 + }
1190 + fn dnd_dropped(
1191 + &mut self,
1192 + _: &Connection,
1193 + _: &QueueHandle<Self>,
1194 + _: &wl_data_source::WlDataSource,
1195 + ) {
1196 + }
1197 + fn dnd_finished(
1198 + &mut self,
1199 + _: &Connection,
1200 + _: &QueueHandle<Self>,
1201 + _: &wl_data_source::WlDataSource,
1202 + ) {
1203 + }
1204 + fn action(
1205 + &mut self,
1206 + _: &Connection,
1207 + _: &QueueHandle<Self>,
1208 + _: &wl_data_source::WlDataSource,
1209 + _: smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction,
1210 + ) {
1211 + }
1212 + }
1213 +
1214 + impl PrimarySelectionDeviceHandler for App {
1215 + fn selection(
1216 + &mut self,
1217 + _: &Connection,
1218 + _: &QueueHandle<Self>,
1219 + _: &zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
1220 + ) {
1221 + }
1222 + }
1223 +
1224 + impl PrimarySelectionSourceHandler for App {
1225 + fn send_request(
1226 + &mut self,
1227 + _: &Connection,
1228 + _: &QueueHandle<Self>,
1229 + source: &zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
1230 + _: String,
1231 + pipe: WritePipe,
1232 + ) {
1233 + if self
1234 + .primary_source
1235 + .as_ref()
1236 + .map(PrimarySelectionSource::inner)
1237 + != Some(source)
1238 + {
1239 + return;
1240 + }
1241 + write_selection(pipe, self.primary_text.as_bytes());
1242 + }
1243 +
1244 + fn cancelled(
1245 + &mut self,
1246 + _: &Connection,
1247 + _: &QueueHandle<Self>,
1248 + source: &zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
1249 + ) {
1250 + if self
1251 + .primary_source
1252 + .as_ref()
1253 + .map(PrimarySelectionSource::inner)
1254 + == Some(source)
1255 + {
1256 + self.primary_source = None;
1257 + self.primary_text.clear();
1258 + // The highlight was standing for "this is what middle-click will
1259 + // paste". It no longer is, so it should stop saying so.
1260 + self.selection = None;
1261 + self.dirty = true;
1262 + }
1263 + }
1264 + }
1265 +
1266 + /// Answer a paste request by writing the text and closing the pipe.
1267 + ///
1268 + /// Blocking, and safe to be: the pipe comes from the program doing the
1269 + /// pasting, which is reading it, and the payload is bounded by the visible
1270 + /// grid because shop has no scrollback to select out of.
1271 + fn write_selection(mut pipe: WritePipe, bytes: &[u8]) {
1272 + if let Err(e) = pipe.write_all(bytes) {
1273 + warn!("selection send: {e}");
1274 + }
1275 + // Dropping the pipe closes the fd, which is what tells the far end the
1276 + // text is complete. Without it a paste hangs waiting for more.
1277 + drop(pipe);
1278 + }
1279 +
1280 + impl PointerHandler for App {
Lines truncated
@@ -43,6 +43,9 @@
43 43 pub bg: [f32; 4],
44 44 /// The cursor, at full strength. The dim phase is derived from it.
45 45 pub cursor: [f32; 4],
46 + /// The selection wash, at full strength. Drawn translucent over the cells
47 + /// it covers, so the text under it stays the colour the program asked for.
48 + pub selection: [f32; 4],
46 49 }
47 50
48 51 impl Palette {
@@ -148,6 +151,11 @@
148 151 fg: intent("content.primary")?,
149 152 bg: intent("surface.page")?,
150 153 cursor: intent("action.primary")?,
154 + // The same intent as the cursor, and named separately anyway: these
155 + // are two different questions a theme could answer differently, and
156 + // one of them wanting to move should not require untangling it from
157 + // the other first.
158 + selection: intent("action.primary")?,
151 159 })
152 160 }
153 161
@@ -1,0 +1,518 @@
1 + //! Mouse selection over the visible grid.
2 + //!
3 + //! A [`Selection`] is the raw gesture: where the drag started, where the
4 + //! pointer is now, and what granularity the click count asked for. It knows
5 + //! nothing about cell contents, so the binary can carry one across events
6 + //! without borrowing the grid.
7 + //!
8 + //! Resolving it against the grid produces a [`SelectionSpan`] — the actual
9 + //! covered cells, with word and line granularity expanded. The renderer asks a
10 + //! span whether a cell is covered; [`Grid::selection_text`] turns one into the
11 + //! string that goes on the clipboard.
12 + //!
13 + //! There is no scrollback in the grid yet, so every coordinate here is a
14 + //! viewport coordinate and a selection dies when its rows scroll off the top.
15 +
16 + use crate::Grid;
17 +
18 + /// Characters that end a word for double-click purposes.
19 + ///
20 + /// Deliberately short. Paths, URLs and flags are the things people
21 + /// double-click in a terminal, so `/`, `.`, `-`, `_`, `~`, `:` and `=` stay
22 + /// inside the word even though a prose-oriented list would split on them.
23 + const WORD_DELIMITERS: &str = " \t\u{a0},;'\"`|()[]{}<>";
24 +
25 + fn is_word_char(c: char) -> bool {
26 + c != '\0' && !WORD_DELIMITERS.contains(c)
27 + }
28 +
29 + /// A cell coordinate in the viewport. Ordered row-major, which is the order
30 + /// text is read out in.
31 + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
32 + pub struct Point {
33 + pub row: u16,
34 + pub col: u16,
35 + }
36 +
37 + impl Point {
38 + pub fn new(row: u16, col: u16) -> Self {
39 + Self { row, col }
40 + }
41 + }
42 +
43 + /// Granularity, set by click count (and Ctrl for the block variant).
44 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
45 + pub enum SelectionMode {
46 + /// Single click: cell to cell.
47 + #[default]
48 + Char,
49 + /// Double click: whole words at both ends.
50 + Word,
51 + /// Triple click: whole rows.
52 + Line,
53 + /// Ctrl+drag: a rectangle rather than a run of text.
54 + Block,
55 + }
56 +
57 + /// One in-progress or finished drag.
58 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
59 + pub struct Selection {
60 + /// Where the button went down. Fixed for the life of the drag.
61 + pub anchor: Point,
62 + /// Where the pointer is now. Moves with every motion event.
63 + pub head: Point,
64 + pub mode: SelectionMode,
65 + }
66 +
67 + impl Selection {
68 + pub fn new(mode: SelectionMode, at: Point) -> Self {
69 + Self {
70 + anchor: at,
71 + head: at,
72 + mode,
73 + }
74 + }
75 +
76 + /// Move the loose end. Called on every pointer motion while the button is
77 + /// held.
78 + pub fn drag_to(&mut self, at: Point) {
79 + self.head = at;
80 + }
81 +
82 + /// True when the drag never left its starting cell, in char mode — the
83 + /// gesture was a plain click, so there is nothing to copy and the binary
84 + /// should drop the selection rather than highlight one cell.
85 + pub fn is_empty(&self) -> bool {
86 + self.mode == SelectionMode::Char && self.anchor == self.head
87 + }
88 +
89 + /// Follow a full-screen scroll of `delta` rows (positive = content moved
90 + /// up, matching [`crate::Damage::scroll`]).
91 + ///
92 + /// Returns `None` once the selection has scrolled entirely off the top,
93 + /// which is the point at which the binary drops it. A selection that is
94 + /// only partly off-screen is clamped to what is still visible: the
95 + /// alternative is losing a long selection the moment one line of output
96 + /// arrives.
97 + pub fn scrolled(mut self, delta: i16, rows: u16) -> Option<Self> {
98 + if delta == 0 {
99 + return Some(self);
100 + }
101 + let last = i32::from(rows.saturating_sub(1));
102 + let shift = |row: u16| i32::from(row) - i32::from(delta);
103 + let (a, h) = (shift(self.anchor.row), shift(self.head.row));
104 + if (a < 0 && h < 0) || (a > last && h > last) {
105 + return None;
106 + }
107 + self.anchor.row = a.clamp(0, last) as u16;
108 + self.head.row = h.clamp(0, last) as u16;
109 + Some(self)
110 + }
111 + }
112 +
113 + /// A selection resolved against grid contents: the cells actually covered.
114 + ///
115 + /// `start` and `end` are both inclusive. For everything but [`SelectionMode::Block`]
116 + /// they bound a row-major run; for `Block` they are opposite corners of a
117 + /// rectangle.
118 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
119 + pub struct SelectionSpan {
120 + pub start: Point,
121 + pub end: Point,
122 + pub block: bool,
123 + }
124 +
125 + impl SelectionSpan {
126 + /// Is this cell inside the selection? Called once per visible cell per
127 + /// frame by the renderer's fill scan.
128 + #[inline]
129 + pub fn contains(&self, row: u16, col: u16) -> bool {
130 + if row < self.start.row || row > self.end.row {
131 + return false;
132 + }
133 + if self.block {
134 + let (lo, hi) = min_max(self.start.col, self.end.col);
135 + return col >= lo && col <= hi;
136 + }
137 + if row == self.start.row && col < self.start.col {
138 + return false;
139 + }
140 + if row == self.end.row && col > self.end.col {
141 + return false;
142 + }
143 + true
144 + }
145 +
146 + /// Inclusive column range covered on `row`. `None` when the row is
147 + /// outside the selection.
148 + ///
149 + /// The renderer draws a selection as one quad per row rather than one per
150 + /// cell, so this is the shape it wants: a run, not a predicate.
151 + pub fn cols_on(&self, row: u16, grid_cols: u16) -> Option<(u16, u16)> {
152 + if row < self.start.row || row > self.end.row {
153 + return None;
154 + }
155 + let last = grid_cols.saturating_sub(1);
156 + if self.block {
157 + let (lo, hi) = min_max(self.start.col, self.end.col);
158 + return Some((lo, hi.min(last)));
159 + }
160 + let lo = if row == self.start.row {
161 + self.start.col
162 + } else {
163 + 0
164 + };
165 + let hi = if row == self.end.row {
166 + self.end.col
167 + } else {
168 + last
169 + };
170 + Some((lo, hi.min(last)))
171 + }
172 + }
173 +
174 + fn min_max(a: u16, b: u16) -> (u16, u16) {
175 + if a <= b { (a, b) } else { (b, a) }
176 + }
177 +
178 + impl Grid {
179 + /// Resolve a gesture into the cells it covers, expanding word and line
180 + /// granularity against the current contents.
181 + pub fn selection_span(&self, sel: &Selection) -> SelectionSpan {
182 + let last_col = self.cols().saturating_sub(1);
183 + let last_row = self.rows().saturating_sub(1);
184 + let clamp = |p: Point| Point::new(p.row.min(last_row), p.col.min(last_col));
185 + let (anchor, head) = (clamp(sel.anchor), clamp(sel.head));
186 +
187 + if sel.mode == SelectionMode::Block {
188 + let (start_row, end_row) = min_max(anchor.row, head.row);
189 + return SelectionSpan {
190 + start: Point::new(start_row, anchor.col),
191 + end: Point::new(end_row, head.col),
192 + block: true,
193 + };
194 + }
195 +
196 + let (mut start, mut end) = if anchor <= head {
197 + (anchor, head)
198 + } else {
199 + (head, anchor)
200 + };
201 + match sel.mode {
202 + SelectionMode::Word => {
203 + start.col = self.word_start(start.row, start.col);
204 + end.col = self.word_end(end.row, end.col);
205 + }
206 + SelectionMode::Line => {
207 + start.col = 0;
208 + end.col = last_col;
209 + }
210 + SelectionMode::Char | SelectionMode::Block => {}
211 + }
212 + SelectionSpan {
213 + start,
214 + end,
215 + block: false,
216 + }
217 + }
218 +
219 + /// The selected text, ready for the clipboard.
220 + ///
221 + /// Two rules, both about not inventing characters the user never saw:
222 + ///
223 + /// - Trailing blanks come off each row. The grid pads every row out to
224 + /// full width, so without the strip a one-word selection spanning two
225 + /// rows would arrive carrying eighty spaces in the middle of it.
226 + /// - A row that ran off the right edge joins the next one with no newline
227 + /// ([`Grid::row_wrapped`]). A wrapped command line has to paste back as
228 + /// the one line it was, or half of it executes on its own.
229 + ///
230 + /// Block selections always break by row: a rectangle out of the middle of
231 + /// the screen is columnar by intent, and the wrap that produced the rows
232 + /// is not part of what was asked for.
233 + pub fn selection_text(&self, sel: &Selection) -> String {
234 + let span = self.selection_span(sel);
235 + let mut out = String::new();
236 + for row in span.start.row..=span.end.row {
237 + let Some((lo, hi)) = span.cols_on(row, self.cols()) else {
238 + continue;
239 + };
240 + if row > span.start.row && (span.block || !self.row_wrapped(row - 1)) {
241 + out.push('\n');
242 + }
243 + let cells = self.row(row);
244 + let line: String = cells[lo as usize..=hi as usize]
245 + .iter()
246 + .map(|cell| match cell.c() {
247 + '\0' => ' ',
248 + c => c,
249 + })
250 + .collect();
251 + // Trailing blanks on a wrapped row are real cells the text ran
252 + // through, not padding — stripping them would eat the space
253 + // between two words that happened to straddle the edge.
254 + if self.row_wrapped(row) && !span.block {
255 + out.push_str(&line);
256 + } else {
257 + out.push_str(line.trim_end());
258 + }
259 + }
260 + out
261 + }
262 +
263 + /// First column of the word containing `col`. A click on a delimiter
264 + /// selects the run of delimiters instead, so double-clicking whitespace
265 + /// gives you the whitespace rather than nothing.
266 + fn word_start(&self, row: u16, col: u16) -> u16 {
267 + let cells = self.row(row);
268 + let wanted = is_word_char(cells[col as usize].c());
269 + let mut c = col;
270 + while c > 0 && is_word_char(cells[c as usize - 1].c()) == wanted {
271 + c -= 1;
272 + }
273 + c
274 + }
275 +
276 + /// Last column of the word containing `col`, inclusive.
277 + fn word_end(&self, row: u16, col: u16) -> u16 {
278 + let cells = self.row(row);
279 + let last = self.cols() - 1;
280 + let wanted = is_word_char(cells[col as usize].c());
281 + let mut c = col;
282 + while c < last && is_word_char(cells[c as usize + 1].c()) == wanted {
283 + c += 1;
284 + }
285 + c
286 + }
287 + }
288 +
289 + #[cfg(test)]
290 + mod tests {
291 + use super::*;
292 + use shop_vt::Parser;
293 +
294 + fn grid_with(lines: &[&str], cols: u16) -> Grid {
295 + let mut grid = Grid::new(cols, lines.len() as u16);
296 + let mut parser = Parser::new();
297 + let joined = lines.join("\r\n");
298 + parser.advance(&mut grid, joined.as_bytes());
299 + grid
300 + }
301 +
302 + /// A grid fed raw bytes, so tests can drive the deferred wrap.
303 + fn grid_fed(cols: u16, rows: u16, bytes: &str) -> Grid {
304 + let mut grid = Grid::new(cols, rows);
305 + let mut parser = Parser::new();
306 + parser.advance(&mut grid, bytes.as_bytes());
307 + grid
308 + }
309 +
310 + fn sel(mode: SelectionMode, from: (u16, u16), to: (u16, u16)) -> Selection {
311 + let mut s = Selection::new(mode, Point::new(from.0, from.1));
312 + s.drag_to(Point::new(to.0, to.1));
313 + s
314 + }
315 +
316 + #[test]
317 + fn char_selection_within_one_row() {
318 + let grid = grid_with(&["hello world"], 20);
319 + let s = sel(SelectionMode::Char, (0, 0), (0, 4));
320 + assert_eq!(grid.selection_text(&s), "hello");
321 + }
322 +
323 + #[test]
324 + fn char_selection_is_direction_agnostic() {
325 + let grid = grid_with(&["hello world"], 20);
326 + let forward = sel(SelectionMode::Char, (0, 6), (0, 10));
327 + let backward = sel(SelectionMode::Char, (0, 10), (0, 6));
328 + assert_eq!(grid.selection_text(&forward), "world");
329 + assert_eq!(grid.selection_text(&backward), "world");
330 + }
331 +
332 + #[test]
333 + fn multi_row_selection_joins_with_newlines_and_strips_padding() {
334 + let grid = grid_with(&["one", "two", "three"], 20);
335 + let s = sel(SelectionMode::Char, (0, 0), (2, 4));
336 + assert_eq!(grid.selection_text(&s), "one\ntwo\nthree");
337 + }
338 +
339 + #[test]
340 + fn multi_row_selection_keeps_partial_first_and_last_rows() {
341 + let grid = grid_with(&["abcdef", "ghijkl"], 20);
342 + let s = sel(SelectionMode::Char, (0, 3), (1, 2));
343 + assert_eq!(grid.selection_text(&s), "def\nghi");
344 + }
345 +
346 + #[test]
347 + fn blank_row_inside_a_selection_stays_blank() {
348 + let grid = grid_with(&["top", "", "bottom"], 20);
349 + let s = sel(SelectionMode::Char, (0, 0), (2, 5));
350 + assert_eq!(grid.selection_text(&s), "top\n\nbottom");
351 + }
352 +
353 + #[test]
354 + fn a_wrapped_line_copies_back_as_one_line() {
355 + let grid = grid_fed(6, 3, "abcdefghij");
356 + let s = sel(SelectionMode::Char, (0, 0), (1, 3));
357 + assert_eq!(grid.selection_text(&s), "abcdefghij");
358 + }
359 +
360 + #[test]
361 + fn a_row_that_filled_exactly_still_breaks_at_the_newline() {
362 + // Six columns of text ended with CR/LF is not a wrap, even though the
363 + // cursor sat on the right edge.
364 + let grid = grid_fed(6, 3, "abcdef\r\nghij");
365 + let s = sel(SelectionMode::Char, (0, 0), (1, 3));
366 + assert_eq!(grid.selection_text(&s), "abcdef\nghij");
367 + }
368 +
369 + #[test]
370 + fn a_space_straddling_the_wrap_survives_the_copy() {
371 + // "ab " fills the row with real spaces before "cd" wraps onto the
372 + // next; trimming them would glue the two words together.
373 + let grid = grid_fed(6, 3, "ab cd");
374 + let s = sel(SelectionMode::Char, (0, 0), (1, 1));
375 + assert_eq!(grid.selection_text(&s), "ab cd");
376 + }
377 +
378 + #[test]
379 + fn three_rows_of_one_wrapped_line_copy_as_one_line() {
380 + let grid = grid_fed(4, 4, "0123456789ab");
381 + let s = sel(SelectionMode::Line, (0, 0), (2, 0));
382 + assert_eq!(grid.selection_text(&s), "0123456789ab");
383 + }
384 +
385 + #[test]
386 + fn a_block_selection_breaks_by_row_even_across_a_wrap() {
387 + let grid = grid_fed(6, 3, "abcdefghijkl");
388 + let s = sel(SelectionMode::Block, (0, 1), (1, 2));
389 + assert_eq!(grid.selection_text(&s), "bc\nhi");
390 + }
391 +
392 + #[test]
393 + fn erasing_to_the_edge_ends_the_continuation() {
394 + // Wrap, then EL0 from the start of the first row: the tail that ran
395 + // off the edge is gone, so the rows are separate lines again.
396 + let grid = grid_fed(6, 3, "abcdefghij\x1b[H\x1b[K");
397 + let s = sel(SelectionMode::Char, (0, 0), (1, 3));
398 + assert_eq!(grid.selection_text(&s), "\nghij");
399 + }
400 +
401 + #[test]
402 + fn word_mode_expands_both_ends() {
403 + let grid = grid_with(&["alpha beta gamma"], 20);
404 + // Anchor inside "beta", head inside "gamma".
405 + let s = sel(SelectionMode::Word, (0, 7), (0, 12));
406 + assert_eq!(grid.selection_text(&s), "beta gamma");
407 + }
408 +
409 + #[test]
410 + fn word_mode_on_a_single_click_takes_the_whole_word() {
411 + let grid = grid_with(&["alpha beta gamma"], 20);
412 + let s = Selection::new(SelectionMode::Word, Point::new(0, 8));
413 + assert_eq!(grid.selection_text(&s), "beta");
414 + }
415 +
416 + #[test]
417 + fn word_mode_keeps_paths_and_flags_intact() {
418 + let grid = grid_with(&["cargo --offline /usr/lib/foo.so"], 40);
419 + let path = Selection::new(SelectionMode::Word, Point::new(0, 20));
420 + assert_eq!(grid.selection_text(&path), "/usr/lib/foo.so");
421 + let flag = Selection::new(SelectionMode::Word, Point::new(0, 8));
422 + assert_eq!(grid.selection_text(&flag), "--offline");
423 + }
424 +
425 + #[test]
426 + fn word_mode_on_a_delimiter_takes_the_delimiter_run() {
427 + let grid = grid_with(&["a b"], 20);
428 + let s = Selection::new(SelectionMode::Word, Point::new(0, 3));
429 + assert_eq!(grid.selection_text(&s), "");
430 + let span = grid.selection_span(&s);
431 + assert_eq!((span.start.col, span.end.col), (1, 4));
432 + }
433 +
434 + #[test]
435 + fn line_mode_takes_whole_rows() {
436 + let grid = grid_with(&["first line", "second line"], 20);
437 + let s = sel(SelectionMode::Line, (0, 4), (1, 2));
438 + assert_eq!(grid.selection_text(&s), "first line\nsecond line");
439 + }
440 +
441 + #[test]
442 + fn block_mode_cuts_a_column_out_of_every_row() {
443 + let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 20);
444 + let s = sel(SelectionMode::Block, (0, 1), (2, 3));
445 + assert_eq!(grid.selection_text(&s), "bcd\nhij\nnop");
446 + }
447 +
448 + #[test]
449 + fn block_mode_is_corner_agnostic() {
450 + let grid = grid_with(&["abcdef", "ghijkl"], 20);
451 + let s = sel(SelectionMode::Block, (1, 3), (0, 1));
452 + assert_eq!(grid.selection_text(&s), "bcd\nhij");
453 + }
454 +
455 + #[test]
456 + fn contains_covers_the_run_not_the_bounding_box() {
457 + let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 6);
458 + let span = grid.selection_span(&sel(SelectionMode::Char, (0, 3), (2, 1)));
459 + assert!(!span.contains(0, 2));
460 + assert!(span.contains(0, 3));
461 + // Middle row is covered end to end.
462 + assert!(span.contains(1, 0));
463 + assert!(span.contains(1, 5));
464 + assert!(span.contains(2, 1));
465 + assert!(!span.contains(2, 2));
466 + assert!(!span.contains(3, 0));
467 + }
468 +
469 + #[test]
470 + fn contains_on_a_block_is_the_bounding_box() {
471 + let grid = grid_with(&["abcdef", "ghijkl", "mnopqr"], 6);
472 + let span = grid.selection_span(&sel(SelectionMode::Block, (0, 3), (2, 1)));
473 + assert!(span.contains(0, 1));
474 + assert!(span.contains(1, 2));
475 + assert!(!span.contains(1, 0));
476 + assert!(!span.contains(1, 4));
477 + }
478 +
479 + #[test]
480 + fn out_of_range_points_clamp_to_the_grid() {
481 + let grid = grid_with(&["abc"], 4);
482 + let s = sel(SelectionMode::Char, (0, 0), (99, 99));
483 + assert_eq!(grid.selection_text(&s), "abc");
484 + }
485 +
486 + #[test]
487 + fn a_plain_click_is_empty() {
488 + let s = Selection::new(SelectionMode::Char, Point::new(2, 5));
489 + assert!(s.is_empty());
490 + let mut dragged = s;
491 + dragged.drag_to(Point::new(2, 6));
492 + assert!(!dragged.is_empty());
493 + // A double click is a selection even without motion.
494 + assert!(!Selection::new(SelectionMode::Word, Point::new(2, 5)).is_empty());
495 + }
496 +
497 + #[test]
498 + fn scrolling_moves_a_selection_up() {
499 + let s = sel(SelectionMode::Char, (4, 0), (6, 3));
500 + let moved = s.scrolled(2, 24).expect("still on screen");
Lines truncated
@@ -1,0 +1,161 @@
1 + //! Clipboard and primary selection: what goes on them, and what comes back.
2 + //!
3 + //! Wayland has two of them and they are not interchangeable. The **clipboard**
4 + //! is the explicit one, filled by Ctrl+Shift+C and read by Ctrl+Shift+V. The
5 + //! **primary selection** fills itself the moment a selection is made and is
6 + //! read by a middle click, with no command ever issued. Terminals have worked
7 + //! this way since X, and collapsing the two so that selecting text clobbers
8 + //! what you copied ten minutes ago is a genuine loss of a working register.
9 + //!
10 + //! The Wayland plumbing for both lives in `main.rs`, because it needs the SCTK
11 + //! handler traits on the app state. What lives here is the part with rules
12 + //! worth testing: turning bytes from another program into bytes the shell
13 + //! should see.
14 +
15 + /// What we offer a paster, best first.
16 + ///
17 + /// The last two are X11 atom names rather than mime types. They are not
18 + /// correct as mime types and they are what a long tail of ported X11 programs
19 + /// still asks for, so offering them costs nothing and occasionally means a
20 + /// paste works at all.
21 + pub(crate) const OFFERED_MIMES: [&str; 4] = [MIME_UTF8, "text/plain", "TEXT", "STRING"];
22 +
23 + /// What we ask for when pasting. Everything that offers text offers this.
24 + pub(crate) const MIME_UTF8: &str = "text/plain;charset=utf-8";
25 +
26 + /// Bracketed paste markers (DECSET 2004).
27 + const PASTE_START: &[u8] = b"\x1b[200~";
28 + const PASTE_END: &[u8] = b"\x1b[201~";
29 +
30 + /// Turn raw clipboard bytes into the byte stream to hand the PTY.
31 + ///
32 + /// Three things happen, in this order:
33 + ///
34 + /// 1. **Newlines become carriage returns.** The shell's line discipline reads
35 + /// Enter as CR, so pasted text carrying LF would arrive as something the
36 + /// user never typed. `\r\n` collapses to one `\r` rather than two.
37 + /// 2. **Any embedded end-marker is removed.** Without this, pasting text that
38 + /// happens to contain `\e[201~` closes the bracket early and everything
39 + /// after it arrives as if it were typed — which is exactly the thing
40 + /// bracketed paste exists to prevent. Stripped whether or not the mode is
41 + /// on, so turning the mode off cannot turn the hole back on.
42 + /// 3. **The payload is wrapped** if the program asked for brackets.
43 + ///
44 + /// Nothing else is filtered. A paste containing escape sequences is a paste
45 + /// the user asked for, and terminals that second-guess it break pasting into
46 + /// an editor.
47 + pub(crate) fn sanitize_paste(raw: &[u8], bracketed: bool) -> Vec<u8> {
48 + let mut body = Vec::with_capacity(raw.len() + PASTE_START.len() + PASTE_END.len());
49 + let mut i = 0;
50 + while i < raw.len() {
51 + if raw[i..].starts_with(PASTE_END) {
52 + i += PASTE_END.len();
53 + continue;
54 + }
55 + match raw[i] {
56 + b'\r' => {
57 + body.push(b'\r');
58 + // Swallow the LF half of a CRLF so one line break stays one.
59 + i += usize::from(raw.get(i + 1) == Some(&b'\n'));
60 + }
61 + b'\n' => body.push(b'\r'),
62 + byte => body.push(byte),
63 + }
64 + i += 1;
65 + }
66 + if !bracketed {
67 + return body;
68 + }
69 + let mut out = Vec::with_capacity(body.len() + PASTE_START.len() + PASTE_END.len());
70 + out.extend_from_slice(PASTE_START);
71 + out.extend_from_slice(&body);
72 + out.extend_from_slice(PASTE_END);
73 + out
74 + }
75 +
76 + #[cfg(test)]
77 + mod tests {
78 + use super::*;
79 +
80 + fn plain(raw: &str) -> String {
81 + String::from_utf8(sanitize_paste(raw.as_bytes(), false)).unwrap()
82 + }
83 +
84 + fn bracketed(raw: &str) -> String {
85 + String::from_utf8(sanitize_paste(raw.as_bytes(), true)).unwrap()
86 + }
87 +
88 + #[test]
89 + fn ordinary_text_passes_through_untouched() {
90 + assert_eq!(plain("cargo test --workspace"), "cargo test --workspace");
91 + }
92 +
93 + #[test]
94 + fn newlines_arrive_as_carriage_returns() {
95 + assert_eq!(plain("one\ntwo"), "one\rtwo");
96 + }
97 +
98 + #[test]
99 + fn a_crlf_pair_is_one_line_break_not_two() {
100 + assert_eq!(plain("one\r\ntwo"), "one\rtwo");
101 + assert_eq!(plain("one\r\n\r\ntwo"), "one\r\rtwo");
102 + }
103 +
104 + #[test]
105 + fn a_lone_carriage_return_survives() {
106 + assert_eq!(plain("one\rtwo"), "one\rtwo");
107 + }
108 +
109 + #[test]
110 + fn a_trailing_newline_becomes_a_trailing_return() {
111 + assert_eq!(plain("make\n"), "make\r");
112 + }
113 +
114 + #[test]
115 + fn brackets_wrap_the_payload() {
116 + assert_eq!(bracketed("ls"), "\x1b[200~ls\x1b[201~");
117 + }
118 +
119 + #[test]
120 + fn an_embedded_end_marker_cannot_close_the_bracket_early() {
121 + // The attack: paste text that ends the bracket and follows it with a
122 + // command, so a shell that would have waited for Enter runs it.
123 + let hostile = "safe\x1b[201~\nrm -rf /\n";
124 + assert_eq!(bracketed(hostile), "\x1b[200~safe\rrm -rf /\r\x1b[201~");
125 + }
126 +
127 + #[test]
128 + fn the_end_marker_is_stripped_even_unbracketed() {
129 + // Belt and braces: the mode can be turned off between the copy and
130 + // the paste, and that must not reopen the hole.
131 + assert_eq!(plain("safe\x1b[201~tail"), "safetail");
132 + }
133 +
134 + #[test]
135 + fn a_start_marker_in_the_payload_is_left_alone() {
136 + // Only the end marker can break out; a stray start marker is inert,
137 + // and stripping it would corrupt text that legitimately contains one.
138 + assert_eq!(plain("\x1b[200~x"), "\x1b[200~x");
139 + }
140 +
141 + #[test]
142 + fn other_escape_sequences_are_the_users_business() {
143 + assert_eq!(plain("\x1b[31mred\x1b[0m"), "\x1b[31mred\x1b[0m");
144 + }
145 +
146 + #[test]
147 + fn an_empty_paste_stays_empty_but_still_brackets() {
148 + assert_eq!(plain(""), "");
149 + assert_eq!(bracketed(""), "\x1b[200~\x1b[201~");
150 + }
151 +
152 + #[test]
153 + fn a_truncated_end_marker_is_not_mistaken_for_one() {
154 + assert_eq!(plain("tail\x1b[201"), "tail\x1b[201");
155 + }
156 +
157 + #[test]
158 + fn utf8_survives_byte_level_handling() {
159 + assert_eq!(plain("héllo → wörld"), "héllo → wörld");
160 + }
161 + }