Skip to main content

max / shop

Report the mouse to programs that ask for it
Author: Max Johnson <me@maxj.phd> · 2026-08-12 14:02 UTC
Signed with PGP, not checked
Commit: 69fa5a4307bcccf6b54be9fd3ae564e52ee90b72
Parent: 7b68d26
3 files changed, +583 insertions, -36 deletions
M Cargo.lock +8 -8
@@ -1968,8 +1968,12 @@
1968 1968 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
1969 1969
1970 1970 [[patch.unused]]
1971 - name = "docengine"
1972 - version = "0.4.0"
1971 + name = "synckit-client"
1972 + version = "0.8.0"
1973 +
1974 + [[patch.unused]]
1975 + name = "synckit-config"
1976 + version = "0.2.0"
1973 1977
1974 1978 [[patch.unused]]
1975 1979 name = "kberg"
@@ -2008,9 +2012,5 @@
2008 2012 version = "0.1.0"
2009 2013
2010 2014 [[patch.unused]]
2011 - name = "synckit-client"
2012 - version = "0.8.0"
2013 -
2014 - [[patch.unused]]
2015 - name = "synckit-config"
2016 - version = "0.2.0"
2015 + name = "docengine"
2016 + version = "0.5.0"
@@ -450,6 +450,121 @@
450 450 pub wrap_next: bool,
451 451 }
452 452
453 + /// How much of the mouse a program has asked to be told about.
454 + ///
455 + /// Strictly increasing: each level includes everything below it, which is why
456 + /// one field holds all of them rather than a flag per DECSET number. Setting
457 + /// any level replaces the previous one, matching xterm — the modes are not
458 + /// composable there either, however much the separate numbers suggest it.
459 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
460 + pub enum MouseTracking {
461 + /// The pointer belongs to the user: shop selects text with it.
462 + #[default]
463 + Off,
464 + /// DECSET 9, X10 compatibility. Presses only, and no modifier bits.
465 + Press,
466 + /// DECSET 1000. Presses and releases.
467 + Click,
468 + /// DECSET 1002. Adds motion, but only while a button is held.
469 + Drag,
470 + /// DECSET 1003. Adds motion with no button down, which is a report per
471 + /// cell crossed for as long as the pointer is over the window.
472 + Motion,
473 + }
474 +
475 + /// How a mouse report is spelled on the wire.
476 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
477 + pub enum MouseEncoding {
478 + /// The original `CSI M Cb Cx Cy`, each field a byte biased by 32.
479 + ///
480 + /// Two consequences worth knowing, and both are why 1006 exists: a
481 + /// coordinate past 223 has no byte to land in and is dropped, and a
482 + /// release does not say which button was let go.
483 + #[default]
484 + X10,
485 + /// DECSET 1006. `CSI < b ; x ; y M` for a press, `m` for a release —
486 + /// decimal, so no coordinate ceiling, and the release keeps its button.
487 + Sgr,
488 + }
489 +
490 + /// Which button a mouse report is about.
491 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
492 + pub enum MouseButton {
493 + Left,
494 + Middle,
495 + Right,
496 + WheelUp,
497 + WheelDown,
498 + /// Motion with nothing held. Only [`MouseTracking::Motion`] asks for it.
499 + None,
500 + }
501 +
502 + impl MouseButton {
503 + /// The low bits the wire spells this button with. Wheel buttons set 64,
504 + /// which is the bit that distinguishes them from a real press.
505 + fn code(self) -> u8 {
506 + match self {
507 + Self::Left => 0,
508 + Self::Middle => 1,
509 + Self::Right => 2,
510 + Self::WheelUp => 64,
511 + Self::WheelDown => 65,
512 + // The same 3 a release uses. Unambiguous in context: this one
513 + // always arrives with the motion bit set.
514 + Self::None => 3,
515 + }
516 + }
517 +
518 + fn is_wheel(self) -> bool {
519 + matches!(self, Self::WheelUp | Self::WheelDown)
520 + }
521 + }
522 +
523 + /// What the pointer did.
524 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
525 + pub enum MouseAction {
526 + Press,
527 + Release,
528 + /// The pointer crossed into another cell. Whether a button is held is read
529 + /// from the report's button, not from here.
530 + Motion,
531 + }
532 +
533 + /// Modifiers held while the pointer did it.
534 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
535 + pub struct MouseMods {
536 + pub shift: bool,
537 + pub alt: bool,
538 + pub ctrl: bool,
539 + }
540 +
541 + impl MouseMods {
542 + fn bits(self) -> u8 {
543 + u8::from(self.shift) * 4 + u8::from(self.alt) * 8 + u8::from(self.ctrl) * 16
544 + }
545 + }
546 +
547 + /// One thing the pointer did, in grid coordinates, ready to be encoded.
548 + ///
549 + /// Cells, 0-based, as the rest of this crate counts them. The +1 the wire
550 + /// wants is applied at encoding time and nowhere else.
551 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
552 + pub struct MouseReport {
553 + pub button: MouseButton,
554 + pub action: MouseAction,
555 + pub col: u16,
556 + pub row: u16,
557 + pub mods: MouseMods,
558 + }
559 +
560 + /// The largest coordinate X10's byte-per-field encoding can carry.
561 + ///
562 + /// A field is `32 + 1 + n` in one byte, so n stops at 222. Past that the
563 + /// report is dropped rather than truncated: a wrong coordinate tells the
564 + /// program the click was somewhere it wasn't, and a missing one tells it
565 + /// nothing, which is the smaller lie.
566 + const X10_COORD_MAX: u16 = 222;
567 +
453 568 /// One row that has scrolled off the top of the main screen.
454 569 ///
455 570 /// Always exactly the grid's current width: `resize` rewraps the whole buffer
@@ -527,8 +642,18 @@
527 642 region_origin: u16,
528 643 cursor: Cursor,
529 644 cursor_shape: CursorShape,
530 - saved_main_cursor: Cursor,
531 - saved_alt_cursor: Cursor,
645 + /// Where each screen's cursor was when the other took over, indexed by
646 + /// `on_alt`. The alt-screen swap's own bookkeeping, and nothing else's.
647 + swap_saved: [Cursor; 2],
648 + /// DECSC's slot, one per screen, indexed by `on_alt`.
649 + ///
650 + /// Separate from `swap_saved` because they answer to different owners and
651 + /// sharing one slot loses saves. They shared one until this was written:
652 + /// `ESC 7` on the alt screen wrote the slot that leaving alt restored
653 + /// from, so a full-screen program that saved its cursor moved the shell's
654 + /// on the way out. Per-screen because that is what xterm does, and an
655 + /// `ESC 7` in vim has nothing to say about where the shell was.
656 + dec_saved: [Cursor; 2],
532 657 scroll_top: u16, // 0-indexed, inclusive
533 658 scroll_bottom: u16, // 0-indexed, inclusive
534 659 pending_fg: Color,
@@ -564,6 +689,12 @@
564 689 // program that wants the wheel to mean something else clears it. Same
565 690 // division as 2004: the grid tracks the mode, the binary acts on it.
566 691 alternate_scroll: bool,
692 + // DECSET 9/1000/1002/1003 and 1006: how much of the mouse the program
693 + // wants, and in which encoding it wants it. Same division again — the grid
694 + // records what was asked for and the binary, which is the only half that
695 + // sees a pointer, does the sending.
696 + mouse_tracking: MouseTracking,
697 + mouse_encoding: MouseEncoding,
567 698 pending_title: Option<String>,
568 699 identity: Identity,
569 700 // Bytes the terminal owes the program, from queries it answered. The grid
@@ -609,8 +740,8 @@
609 740 ..Cursor::default()
610 741 },
611 742 cursor_shape: CursorShape::Block,
612 - saved_main_cursor: Cursor::default(),
613 - saved_alt_cursor: Cursor::default(),
743 + swap_saved: [Cursor::default(); 2],
744 + dec_saved: [Cursor::default(); 2],
614 745 scroll_top: 0,
615 746 scroll_bottom: rows - 1,
616 747 pending_fg: Color::Default,
@@ -624,6 +755,8 @@
624 755 cursor_keys_application: false,
625 756 keypad_application: false,
626 757 alternate_scroll: true,
758 + mouse_tracking: MouseTracking::Off,
759 + mouse_encoding: MouseEncoding::X10,
627 760 pending_title: None,
628 761 identity: Identity::default(),
629 762 pending_replies: Vec::new(),
@@ -748,6 +881,89 @@
748 881 self.alternate_scroll
749 882 }
750 883
884 + /// How much of the mouse the program has asked for. [`MouseTracking::Off`]
885 + /// means the pointer is the user's, for selecting text.
886 + pub fn mouse_tracking(&self) -> MouseTracking {
887 + self.mouse_tracking
888 + }
889 +
890 + /// Which spelling a mouse report should use.
891 + pub fn mouse_encoding(&self) -> MouseEncoding {
892 + self.mouse_encoding
893 + }
894 +
895 + /// The bytes this pointer event owes the program, or `None` when the
896 + /// program did not ask for it.
897 + ///
898 + /// Filtering lives here rather than at the call site because the levels
899 + /// are what decides it, and the levels are this crate's business. A
900 + /// caller reports everything the pointer does and lets the answer decide.
901 + pub fn encode_mouse(&self, r: MouseReport) -> Option<Vec<u8>> {
902 + if self.mouse_tracking == MouseTracking::Off || !self.mouse_coords_fit(r.col, r.row) {
903 + return None;
904 + }
905 + // The wheel has no release and no drag; it is a press or it is
906 + // nothing, at every level that reports the mouse at all.
907 + if r.button.is_wheel() {
908 + return (r.action == MouseAction::Press).then(|| self.spell_mouse(r));
909 + }
910 + let wanted = match r.action {
911 + MouseAction::Press => true,
912 + MouseAction::Release => self.mouse_tracking >= MouseTracking::Click,
913 + MouseAction::Motion if r.button == MouseButton::None => {
914 + self.mouse_tracking == MouseTracking::Motion
915 + }
916 + MouseAction::Motion => self.mouse_tracking >= MouseTracking::Drag,
917 + };
918 + wanted.then(|| self.spell_mouse(r))
919 + }
920 +
921 + fn spell_mouse(&self, r: MouseReport) -> Vec<u8> {
922 + let mut cb = r.button.code();
923 + if r.action == MouseAction::Motion {
924 + cb += 32;
925 + }
926 + // X10 compatibility mode predates modifier reporting, and a program
927 + // that asked for it is parsing three fixed bytes.
928 + if self.mouse_tracking != MouseTracking::Press {
929 + cb += r.mods.bits();
930 + }
931 + match self.mouse_encoding {
932 + MouseEncoding::Sgr => {
933 + let end = if r.action == MouseAction::Release {
934 + 'm'
935 + } else {
936 + 'M'
937 + };
938 + format!("\x1b[<{};{};{}{end}", cb, r.col + 1, r.row + 1).into_bytes()
939 + }
940 + MouseEncoding::X10 => {
941 + // The button a release let go of has nowhere to be spelled
942 + // here; 3 is "some button came up" and it is all the program
943 + // gets. This is the limitation 1006 exists to lift.
944 + if r.action == MouseAction::Release {
945 + cb = 3 + if self.mouse_tracking == MouseTracking::Press {
946 + 0
947 + } else {
948 + r.mods.bits()
949 + };
950 + }
951 + let mut out = vec![0x1b, b'[', b'M', 32 + cb];
952 + out.push(32 + 1 + r.col as u8);
953 + out.push(32 + 1 + r.row as u8);
954 + out
955 + }
956 + }
957 + }
958 +
959 + /// Whether a report at these coordinates can be spelled at all.
960 + ///
961 + /// Only X10 can fail, and only past its byte ceiling. Checked separately
962 + /// from encoding so a caller can drop the event before doing the work.
963 + pub fn mouse_coords_fit(&self, col: u16, row: u16) -> bool {
964 + self.mouse_encoding == MouseEncoding::Sgr || (col <= X10_COORD_MAX && row <= X10_COORD_MAX)
965 + }
966 +
751 967 /// Whether the alt screen is the active one.
752 968 ///
753 969 /// The alt screen keeps no history, which is why anything deciding what a
@@ -1769,6 +1985,199 @@
1769 1985 }
1770 1986 }
1771 1987
1988 + /// DECSC. Per-screen, so the alt screen's save cannot reach the main
1989 + /// screen's slot.
1990 + fn save_cursor(&mut self) {
1991 + self.dec_saved[self.on_alt as usize] = self.cursor;
1992 + }
1993 +
1994 + /// DECRC. A restore with no matching save puts the cursor home, which is
1995 + /// what the default slot holds.
1996 + fn restore_cursor(&mut self) {
1997 + let mut c = self.dec_saved[self.on_alt as usize];
1998 + // The screen may have shrunk since the save. A cursor off the end of
1999 + // it is not a position anything can draw at.
2000 + c.row = c.row.min(self.rows - 1);
2001 + c.col = c.col.min(self.cols - 1);
2002 + self.cursor = c;
2003 + }
2004 +
2005 + /// Whether logical row `r` of the active screen runs onto the next.
2006 + ///
2007 + /// Screen-relative, unlike the public [`row_wrapped`](Self::row_wrapped),
2008 + /// which takes a viewport row and may answer out of history. The row
2009 + /// movers below deal in screen rows, and reading the viewport's numbering
2010 + /// here would move the wrong flags whenever the user had scrolled back.
2011 + fn screen_row_wrapped(&self, r: u16) -> bool {
2012 + let phys = self.phys_row(r) as usize;
2013 + let flags = if self.on_alt {
2014 + &self.alt_wrapped
2015 + } else {
2016 + &self.main_wrapped
2017 + };
2018 + flags.get(phys).copied().unwrap_or(false)
2019 + }
2020 +
2021 + /// Copy one whole screen row onto another, contents and wrap flag both.
2022 + ///
2023 + /// Goes through `row_start` per row rather than moving a span: logical
2024 + /// rows are a ring, so two rows adjacent on screen need not be adjacent in
2025 + /// memory, and a bulk move would shuffle the ring instead of the screen.
2026 + fn copy_row(&mut self, src: u16, dst: u16) {
2027 + if src == dst {
2028 + return;
2029 + }
2030 + let wrapped = self.screen_row_wrapped(src);
2031 + let (s, d) = (self.row_start(src), self.row_start(dst));
2032 + let cols = self.cols as usize;
2033 + self.active_cells_mut().copy_within(s..s + cols, d);
2034 + self.set_row_wrapped(dst, wrapped);
2035 + }
2036 +
2037 + fn blank_screen_row(&mut self, r: u16) {
2038 + self.erase_line_range(r, 0, self.cols);
2039 + self.set_row_wrapped(r, false);
2040 + }
2041 +
2042 + /// Blank any half of a wide pair whose other half is gone.
2043 + ///
2044 + /// The column movers shift a run of cells sideways, and a shift can cut a
2045 + /// pair in two: the lead of a wide character can be pushed off the right
2046 + /// edge, or a spacer can be pulled away from its lead. Either half left
2047 + /// alone is a cell lying about what it holds — the same reasoning
2048 + /// `erase_line_range` applies at its ends, applied to the whole row
2049 + /// because a shift can break a pair anywhere along it.
2050 + fn heal_wide_pairs(&mut self, row: u16) {
2051 + let start = self.row_start(row);
2052 + let cols = self.cols as usize;
2053 + let cells = &mut self.active_cells_mut()[start..start + cols];
2054 + for i in 0..cols {
2055 + let orphan_lead = cells[i].is_wide() && !cells.get(i + 1).is_some_and(Cell::is_spacer);
2056 + let orphan_spacer = cells[i].is_spacer() && !(i > 0 && cells[i - 1].is_wide());
2057 + if orphan_lead || orphan_spacer {
2058 + cells[i] = Cell::default();
2059 + }
2060 + }
2061 + }
2062 +
2063 + /// IL. Open `n` blank lines at the cursor, pushing what follows down and
2064 + /// off the bottom of the scrolling region.
2065 + ///
2066 + /// Ignored when the cursor sits outside the region: the region is the part
2067 + /// of the screen the program has claimed, and an insert from outside it
2068 + /// would move rows it does not own.
2069 + fn insert_lines(&mut self, n: u16) {
2070 + if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom {
2071 + return;
2072 + }
2073 + let top = self.cursor.row;
2074 + let n = n.min(self.scroll_bottom - top + 1);
2075 + if n == 0 {
2076 + return;
2077 + }
2078 + // Downward, so a row is read before the copy that overwrites it.
2079 + for r in (top + n..=self.scroll_bottom).rev() {
2080 + self.copy_row(r - n, r);
2081 + }
2082 + for r in top..top + n {
2083 + self.blank_screen_row(r);
2084 + }
2085 + // The row above the opening no longer runs into what is now a blank.
2086 + if top > 0 {
2087 + self.set_row_wrapped(top - 1, false);
2088 + }
2089 + for r in top..=self.scroll_bottom {
2090 + self.mark_row_dirty(r);
2091 + }
2092 + // DEC puts the cursor at the left margin, and enough programs rely on
2093 + // it that leaving the column alone is the surprising choice.
2094 + self.cursor.col = 0;
2095 + self.cursor.wrap_next = false;
2096 + }
2097 +
2098 + /// DL. Remove `n` lines at the cursor, pulling the rest of the scrolling
2099 + /// region up and blanking what it vacates at the bottom.
2100 + fn delete_lines(&mut self, n: u16) {
2101 + if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom {
2102 + return;
2103 + }
2104 + let top = self.cursor.row;
2105 + let n = n.min(self.scroll_bottom - top + 1);
2106 + if n == 0 {
2107 + return;
2108 + }
2109 + for r in top..=self.scroll_bottom - n {
2110 + self.copy_row(r + n, r);
2111 + }
2112 + for r in self.scroll_bottom + 1 - n..=self.scroll_bottom {
2113 + self.blank_screen_row(r);
2114 + }
2115 + if top > 0 {
2116 + self.set_row_wrapped(top - 1, false);
2117 + }
2118 + for r in top..=self.scroll_bottom {
2119 + self.mark_row_dirty(r);
2120 + }
2121 + self.cursor.col = 0;
2122 + self.cursor.wrap_next = false;
2123 + }
2124 +
2125 + /// ICH. Open `n` blank cells at the cursor, pushing the rest of the line
2126 + /// right and off the edge. The cursor does not move.
2127 + fn insert_chars(&mut self, n: u16) {
2128 + let row = self.cursor.row;
2129 + let col = self.cursor.col;
2130 + let cols = self.cols;
2131 + let n = n.min(cols - col);
2132 + if n == 0 {
2133 + return;
2134 + }
2135 + let start = self.row_start(row);
2136 + let (c, k, w) = (col as usize, n as usize, cols as usize);
2137 + let cells = &mut self.active_cells_mut()[start..start + w];
2138 + cells.copy_within(c..w - k, c + k);
2139 + for cell in &mut cells[c..c + k] {
2140 + *cell = Cell::default();
2141 + }
2142 + self.heal_wide_pairs(row);
2143 + // Whatever ran off the right edge is gone, so the line stops here.
2144 + self.set_row_wrapped(row, false);
2145 + self.mark_row_dirty(row);
2146 + }
2147 +
2148 + /// DCH. Remove `n` cells at the cursor, pulling the rest of the line left
2149 + /// and blanking the tail it vacates.
2150 + fn delete_chars(&mut self, n: u16) {
2151 + let row = self.cursor.row;
2152 + let col = self.cursor.col;
2153 + let cols = self.cols;
2154 + let n = n.min(cols - col);
2155 + if n == 0 {
2156 + return;
2157 + }
2158 + let start = self.row_start(row);
2159 + let (c, k, w) = (col as usize, n as usize, cols as usize);
2160 + let cells = &mut self.active_cells_mut()[start..start + w];
2161 + cells.copy_within(c + k..w, c);
2162 + for cell in &mut cells[w - k..] {
2163 + *cell = Cell::default();
2164 + }
2165 + self.heal_wide_pairs(row);
2166 + self.set_row_wrapped(row, false);
2167 + self.mark_row_dirty(row);
2168 + }
2169 +
2170 + /// ECH. Blank `n` cells from the cursor without moving anything. The
2171 + /// difference from DCH is the whole point: the tail of the line stays
2172 + /// where it is.
2173 + fn erase_chars(&mut self, n: u16) {
2174 + let row = self.cursor.row;
2175 + let col = self.cursor.col;
2176 + let end = col.saturating_add(n).min(self.cols);
2177 + self.erase_line_range(row, col, end);
2178 + self.mark_row_dirty(row);
2179 + }
2180 +
1772 2181 fn erase_line(&mut self, mode: u16) {
1773 2182 let row = self.cursor.row;
1774 2183 let col = self.cursor.col;
@@ -1848,17 +2257,17 @@
1848 2257 // they were reading before vim opened.
1849 2258 self.view_offset = 0;
1850 2259 if to_alt {
1851 - self.saved_main_cursor = self.cursor;
2260 + self.swap_saved[0] = self.cursor;
1852 2261 self.on_alt = true;
1853 2262 for cell in &mut self.alt {
1854 2263 *cell = Cell::default();
1855 2264 }
1856 2265 self.alt_wrapped.fill(false);
1857 - self.cursor = self.saved_alt_cursor;
2266 + self.cursor = self.swap_saved[1];
1858 2267 } else {
1859 - self.saved_alt_cursor = self.cursor;
2268 + self.swap_saved[1] = self.cursor;
1860 2269 self.on_alt = false;
1861 - self.cursor = self.saved_main_cursor;
2270 + self.cursor = self.swap_saved[0];
1862 2271 }
1863 2272 self.pending_screen_swap = true;
1864 2273 self.mark_all_rows_dirty();
@@ -2088,6 +2497,17 @@
2088 2497 ('K', false) => {
2089 2498 self.erase_line(param1(params, 0));
2090 2499 }
2500 + ('L', false) => self.insert_lines(param1(params, 1)),
2501 + ('M', false) => self.delete_lines(param1(params, 1)),
2502 + ('@', false) => self.insert_chars(param1(params, 1)),
2503 + ('P', false) => self.delete_chars(param1(params, 1)),
2504 + ('X', false) => self.erase_chars(param1(params, 1)),
2505 + // DECSC/DECRC in their CSI spelling, the same pair as `ESC 7` and
2506 + // `ESC 8`. `CSI s` is DECSLRM under DECLRMM, which shop does not
2507 + // implement and no program can have turned on, so there is nothing
2508 + // for it to be mistaken for here.
2509 + ('s', false) if intermediates.is_empty() => self.save_cursor(),
2510 + ('u', false) if intermediates.is_empty() => self.restore_cursor(),
2091 2511 // DA1, "what are you". Guarded on empty intermediates because
2092 2512 // `CSI > c` is DA2, a different question, and the private-flag
2093 2513 // check above only screens for `?`.
@@ -2097,6 +2517,35 @@
2097 2517 // sixel, and claiming it means a client picks sixel over kitty
2098 2518 // graphics and draws nothing.
2099 2519 ('c', false) if intermediates.is_empty() => self.reply(b"\x1b[?62;22c"),
Lines truncated
@@ -21,7 +21,10 @@
21 21 use calloop::{EventLoop, LoopHandle};
22 22 use calloop_wayland_source::WaylandSource;
23 23 use kittygfx as kgp;
24 - use shop_grid::{Color as GridColor, CursorShape, Grid, Point, Selection, SelectionMode};
24 + use shop_grid::{
25 + Color as GridColor, CursorShape, Grid, MouseAction, MouseButton, MouseMods, MouseReport,
26 + MouseTracking, Point, Selection, SelectionMode,
27 + };
25 28 use shop_pty::{Pty, PtySize};
26 29 use shop_render::{BgFill, CellText, ImagePlacement, ImageRenderer, TextRenderer};
27 30 use shop_wayland::{
@@ -99,8 +102,20 @@
99 102
100 103 // Linux input event codes, as `wl_pointer.button` reports them.
101 104 const BTN_LEFT: u32 = 0x110;
105 + const BTN_RIGHT: u32 = 0x111;
102 106 const BTN_MIDDLE: u32 = 0x112;
103 107
108 + /// The three buttons a terminal has a number for. Anything else on the mouse
109 + /// is not reportable, so it is left alone rather than folded into one of these.
110 + fn mouse_button(button: u32) -> Option<MouseButton> {
111 + match button {
112 + BTN_LEFT => Some(MouseButton::Left),
113 + BTN_MIDDLE => Some(MouseButton::Middle),
114 + BTN_RIGHT => Some(MouseButton::Right),
115 + _ => None,
116 + }
117 + }
118 +
104 119 fn main() -> anyhow::Result<()> {
105 120 tracing_subscriber::fmt()
106 121 .with_env_filter(
@@ -299,6 +314,8 @@
299 314 pointer_at: (0.0, 0.0),
300 315 selection: None,
301 316 dragging: false,
317 + mouse_held: None,
318 + mouse_last_cell: None,
302 319 last_click: None,
303 320 click_count: 0,
304 321 last_serial: 0,
@@ -751,6 +768,15 @@
751 768 selection: Option<Selection>,
752 769 /// Left button is down and the head is following the pointer.
753 770 dragging: bool,
771 + /// Which button a program tracking the mouse believes is held, and the cell
772 + /// it last heard about.
773 + ///
774 + /// Both exist to keep motion honest. A drag report has to name the button
775 + /// being dragged, and wayland's motion event does not carry one; and the
776 + /// pointer moves in pixels while a report is about cells, so without the
777 + /// last cell every pixel of travel inside one cell would be another report.
778 + mouse_held: Option<MouseButton>,
779 + mouse_last_cell: Option<Point>,
754 780 /// Time and cell of the last left press, for deciding whether the next one
755 781 /// is a double or triple click.
756 782 last_click: Option<(u32, Point)>,
@@ -1512,31 +1538,48 @@
1512 1538 self.drag_selection();
1513 1539 }
1514 1540 PointerEventKind::Motion { .. } => {
1515 - self.drag_selection();
1541 + // A held button makes this a drag; nothing held makes it
1542 + // the hover that only the any-motion level asked for.
1543 + let button = self.mouse_held.unwrap_or(MouseButton::None);
1544 + if !self.report_mouse(button, MouseAction::Motion) {
1545 + self.drag_selection();
1546 + }
1516 1547 }
1517 1548 PointerEventKind::Press {
1518 - button: BTN_LEFT,
1549 + button,
1519 1550 time,
1520 1551 serial,
1521 - } => {
1552 + } if mouse_button(button).is_some() => {
1522 1553 self.last_serial = serial;
1523 - self.begin_selection(time);
1554 + let named = mouse_button(button).expect("guarded above");
1555 + if self.report_mouse(named, MouseAction::Press) {
1556 + self.mouse_held = Some(named);
1557 + } else if button == BTN_LEFT {
1558 + self.begin_selection(time);
1559 + } else if button == BTN_MIDDLE {
1560 + self.paste_primary();
1561 + }
1524 1562 }
1525 - PointerEventKind::Release {
1526 - button: BTN_LEFT,
1527 - serial,
1528 - ..
1529 - } => {
1563 + PointerEventKind::Release { button, serial, .. }
1564 + if mouse_button(button).is_some() =>
1565 + {
1530 1566 self.last_serial = serial;
1531 - self.finish_selection();
1532 - }
1533 - PointerEventKind::Press {
1534 - button: BTN_MIDDLE,
1535 - serial,
1536 - ..
1537 - } => {
1538 - self.last_serial = serial;
1539 - self.paste_primary();
1567 + let named = mouse_button(button).expect("guarded above");
1568 + // Cleared before the report, not after: a release ends the
1569 + // hold whether or not the program wanted to hear about it,
1570 + // and a stale hold would label every later hover a drag.
1571 + if self.mouse_held == Some(named) {
1572 + self.mouse_held = None;
1573 + }
1574 + if !self.report_mouse(named, MouseAction::Release) && button == BTN_LEFT {
1575 + self.finish_selection();
1576 + } else if button == BTN_LEFT {
1577 + // A program can start tracking between a press and its
1578 + // release, and then this release is the only end the
1579 + // drag will ever get. Without it the pointer keeps
1580 + // dragging a selection nobody can see.
1581 + self.dragging = false;
1582 + }
1540 1583 }
1541 1584 PointerEventKind::Axis { vertical, .. } => {
1542 1585 // `discrete` is notches where the compositor reports them
@@ -1552,7 +1595,21 @@
1552 1595 } else {
1553 1596 0
1554 1597 };
1555 - self.apply_wheel(notches);
1598 + // A tracking program gets one report per notch and does
1599 + // its own scrolling; the local wheel does not also run, or
1600 + // the pager would move twice.
1601 + let button = if notches < 0 {
1602 + MouseButton::WheelUp
1603 + } else {
1604 + MouseButton::WheelDown
1605 + };
1606 + let mut taken = false;
1607 + for _ in 0..notches.unsigned_abs() {
1608 + taken = self.report_mouse(button, MouseAction::Press);
1609 + }
1610 + if !taken {
1611 + self.apply_wheel(notches);
1612 + }
1556 1613 }
1557 1614 PointerEventKind::Leave { .. } => {
1558 1615 // Keep the drag alive: the pointer leaving the window
@@ -1825,6 +1882,50 @@
1825 1882 }
1826 1883
1827 1884 /// Act on a wheel turn of `notches`, negative for up.
1885 + /// Hand a pointer event to the program if it asked for the mouse.
1886 + ///
1887 + /// Returns whether the program took it. `false` means the pointer is still
1888 + /// the user's and the caller should do the local thing — select, paste,
1889 + /// scroll — so every call site reads as "the program first, then us".
1890 + ///
1891 + /// Shift is the override, as in every other terminal: holding it keeps the
1892 + /// pointer local even under a program that is tracking, which is the only
1893 + /// way to select text out of a full-screen application.
1894 + fn report_mouse(&mut self, button: MouseButton, action: MouseAction) -> bool {
1895 + if self.grid.mouse_tracking() == MouseTracking::Off || self.modifiers.shift {
1896 + return false;
1897 + }
1898 + let at = cell_at(self.pointer_at, self.grid.cols(), self.grid.rows());
1899 + // Motion is continuous and reports are per cell, so a move that has
1900 + // not left its cell has nothing to say. Presses and releases always
1901 + // do, however still the pointer was.
1902 + if action == MouseAction::Motion && self.mouse_last_cell == Some(at) {
1903 + return true;
1904 + }
1905 + self.mouse_last_cell = Some(at);
1906 + let report = MouseReport {
1907 + button,
1908 + action,
1909 + col: at.col,
1910 + row: at.row,
1911 + mods: MouseMods {
1912 + shift: false,
1913 + alt: self.modifiers.alt,
1914 + ctrl: self.modifiers.ctrl,
1915 + },
1916 + };
1917 + // The program is tracking either way. A level that did not ask for
1918 + // this particular event still owns the pointer, so `true` even when
1919 + // there are no bytes: falling through to the local path would select
1920 + // text under an application that thinks it holds the mouse.
1921 + if let Some(bytes) = self.grid.encode_mouse(report)
1922 + && let Err(e) = self.pty.write(&bytes)
1923 + {
1924 + warn!("pty write (mouse): {e}");
1925 + }
1926 + true
1927 + }
1928 +
1828 1929 fn apply_wheel(&mut self, notches: i32) {
1829 1930 let modes = shop_xkb::Modes {
1830 1931 cursor_keys_application: self.grid.cursor_keys_application(),
@@ -2083,6 +2184,32 @@
2083 2184 items.iter().map(|s| (*s).to_string()).collect()
2084 2185 }
2085 2186
2187 + // ---- the mouse ------------------------------------------------------
2188 +
2189 + #[test]
2190 + fn the_three_reportable_buttons_have_numbers_and_the_rest_do_not() {
2191 + assert_eq!(mouse_button(BTN_LEFT), Some(MouseButton::Left));
2192 + assert_eq!(mouse_button(BTN_MIDDLE), Some(MouseButton::Middle));
2193 + assert_eq!(mouse_button(BTN_RIGHT), Some(MouseButton::Right));
2194 + // A thumb button. There is no report for it, and inventing one would
2195 + // tell the program a different button was pressed.
2196 + assert_eq!(mouse_button(0x113), None);
2197 + }
2198 +
2199 + #[test]
2200 + fn a_wheel_notch_is_up_on_the_same_sign_the_local_scroll_reads() {
2201 + // The two paths have to agree, or a program tracking the mouse would
2202 + // scroll the opposite way from the same turn of the wheel.
2203 + assert!(matches!(
2204 + wheel_action(-1, false, true, NORMAL),
2205 + WheelAction::Up(_)
2206 + ));
2207 + assert!(matches!(
2208 + wheel_action(1, false, true, NORMAL),
2209 + WheelAction::Down(_)
2210 + ));
2211 + }
2212 +
2086 2213 // ---- the wheel ------------------------------------------------------
2087 2214
2088 2215 const NORMAL: shop_xkb::Modes = shop_xkb::Modes {