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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
//! Custom text selection state for mouse-based copy.
use ratatui::layout::Rect;
/// A position within the content (line index + character offset).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextPosition {
/// Index into the wrapped line array.
pub line_index: usize,
/// Character (column) offset within the wrapped line.
pub char_offset: usize,
}
impl TextPosition {
pub fn new(line_index: usize, char_offset: usize) -> Self {
Self {
line_index,
char_offset,
}
}
}
impl PartialOrd for TextPosition {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TextPosition {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.line_index
.cmp(&other.line_index)
.then(self.char_offset.cmp(&other.char_offset))
}
}
/// A selection range defined by anchor (where mouse-down happened) and cursor (current drag position).
#[derive(Debug, Clone, Copy)]
pub struct SelectionRange {
/// Where the selection started (mouse-down).
pub anchor: TextPosition,
/// Current end of selection (follows the mouse).
pub cursor: TextPosition,
}
impl SelectionRange {
/// Returns (start, end) in document order.
pub fn ordered(&self) -> (TextPosition, TextPosition) {
if self.anchor <= self.cursor {
(self.anchor, self.cursor)
} else {
(self.cursor, self.anchor)
}
}
/// Returns true if the given line index is within the selection.
pub fn contains_line(&self, line_index: usize) -> bool {
let (start, end) = self.ordered();
line_index >= start.line_index && line_index <= end.line_index
}
/// Returns the column range selected on a given line.
/// Returns (start_col, end_col) where end_col is exclusive.
pub fn columns_on_line(&self, line_index: usize, line_width: usize) -> Option<(usize, usize)> {
let (start, end) = self.ordered();
if line_index < start.line_index || line_index > end.line_index {
return None;
}
let col_start = if line_index == start.line_index {
start.char_offset
} else {
0
};
let col_end = if line_index == end.line_index {
end.char_offset
} else {
line_width
};
if col_start >= col_end {
None
} else {
Some((col_start, col_end))
}
}
}
/// State for tracking an active text selection.
#[derive(Debug, Default)]
pub struct SelectionState {
/// Whether a selection is currently active (mouse button held).
pub active: bool,
/// The current selection range (if any text is selected).
pub range: Option<SelectionRange>,
/// The conversation content area rect (set after each render).
pub conversation_area: Rect,
/// The actual scroll position (lines from top) used in the last render.
pub actual_scroll: usize,
/// Total content lines in the last render.
pub total_content_lines: usize,
/// Auto-scroll direction: -1 = up (toward top), 1 = down (toward bottom), None = no auto-scroll.
pub auto_scroll_direction: Option<i8>,
}
impl SelectionState {
/// Map a screen position (col, row) to a content-space TextPosition.
///
/// - `col`, `row`: absolute terminal coordinates
/// - Returns `None` if the position is outside the conversation area.
pub fn screen_to_text_position(&self, col: u16, row: u16) -> Option<TextPosition> {
let area = self.conversation_area;
if area.width == 0 || area.height == 0 {
return None;
}
// Allow slightly out-of-area for auto-scroll (clamp later)
let rel_col = col.saturating_sub(area.x) as usize;
let rel_row = if row < area.y {
0
} else {
(row - area.y) as usize
};
let line_index = self.actual_scroll + rel_row;
let char_offset = rel_col.min(area.width as usize);
Some(TextPosition::new(line_index, char_offset))
}
/// Check if a screen position is within the conversation area.
pub fn is_in_conversation_area(&self, col: u16, row: u16) -> bool {
let area = self.conversation_area;
col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height
}
/// Clear the selection state.
pub fn clear(&mut self) {
self.active = false;
self.range = None;
self.auto_scroll_direction = None;
}
/// Start a selection at the given screen position.
pub fn start(&mut self, col: u16, row: u16) {
if let Some(pos) = self.screen_to_text_position(col, row) {
self.active = true;
self.range = Some(SelectionRange {
anchor: pos,
cursor: pos,
});
self.auto_scroll_direction = None;
}
}
/// Extend the selection to the given screen position (during drag).
pub fn extend(&mut self, col: u16, row: u16) {
let area = self.conversation_area;
// Set auto-scroll direction based on position relative to conversation area
if row < area.y + 1 {
self.auto_scroll_direction = Some(-1); // scroll up
} else if row >= area.y + area.height.saturating_sub(1) {
self.auto_scroll_direction = Some(1); // scroll down
} else {
self.auto_scroll_direction = None;
}
if let Some(pos) = self.screen_to_text_position(col, row)
&& let Some(ref mut range) = self.range
{
range.cursor = pos;
}
}
/// Finalize the selection on mouse-up. Returns true if there's a non-empty selection.
pub fn finalize(&mut self) -> bool {
self.active = false;
self.auto_scroll_direction = None;
self.range.is_some_and(|r| {
let (start, end) = r.ordered();
start != end
})
}
}
#[cfg(test)]
#[path = "selection_tests.rs"]
mod tests;