max / shop
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |