1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// SPDX-License-Identifier: MIT OR Apache-2.0
//! 2026-09-26: Dragging a selection across the dashboard, and reading its
//! text back out of the rendered frame. `TerminalGuard` enables mouse
//! capture, so the terminal forwards drags here instead of drawing its own
//! highlight.
//!
//! A selection is linear, in reading order: from the start cell to the end
//! cell, with whole lines in between. A drag across a pane border therefore
//! picks up cells of the pane beside it; [`extract`] trims trailing whitespace
//! per line.
//!
//! Owner: server tui.
//! Invariants: none beyond the types.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
/// 2026-09-26: An in-progress or finished drag, in terminal cell
/// coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Selection {
/// 2026-09-26: Where the button went down. Fixed for the life of the
/// drag.
pub anchor: (u16, u16),
/// 2026-09-26: Where the pointer is now.
pub cursor: (u16, u16),
}
impl Selection {
pub fn new(at: (u16, u16)) -> Self {
Self {
anchor: at,
cursor: at,
}
}
/// 2026-09-26: `(start, end)` in reading order, whichever way the drag
/// went.
pub fn ordered(&self) -> ((u16, u16), (u16, u16)) {
let (ax, ay) = self.anchor;
let (cx, cy) = self.cursor;
if (ay, ax) <= (cy, cx) {
(self.anchor, self.cursor)
} else {
(self.cursor, self.anchor)
}
}
/// 2026-09-26: Whether the cell is inside the selection.
pub fn contains(&self, x: u16, y: u16) -> bool {
let ((sx, sy), (ex, ey)) = self.ordered();
if y < sy || y > ey {
return false;
}
// 2026-09-26: On one line, a column range. Otherwise the first line
// runs to the right edge and the last starts at the left edge.
if sy == ey {
return x >= sx && x <= ex;
}
if y == sy {
return x >= sx;
}
if y == ey {
return x <= ex;
}
true
}
/// 2026-09-26: Whether the pointer moved. The mouse-up handler copies only
/// a drag, so a plain click copies nothing (`events.rs`).
pub fn is_drag(&self) -> bool {
self.anchor != self.cursor
}
}
/// 2026-09-26: The text under a selection, read out of the rendered frame.
///
/// Trailing whitespace is trimmed per line, blank lines inside the selection
/// are kept and trailing blank lines are dropped. Returns an empty string
/// when nothing is covered.
pub fn extract(buf: &Buffer, area: Rect, sel: &Selection) -> String {
let mut lines: Vec<String> = Vec::new();
let ((_, sy), (_, ey)) = sel.ordered();
for y in sy..=ey {
if y < area.y || y >= area.y.saturating_add(area.height) {
continue;
}
let mut line = String::new();
for x in area.x..area.x.saturating_add(area.width) {
if !sel.contains(x, y) {
continue;
}
line.push_str(buf[(x, y)].symbol());
}
lines.push(line.trim_end().to_string());
}
// 2026-09-26: Trailing blank lines come from dragging past the content.
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
#[cfg(test)]
#[path = "selection_tests.rs"]
mod tests;