1use ratatui::layout::Rect;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct TextPosition {
8 pub line_index: usize,
10 pub char_offset: usize,
12}
13
14impl TextPosition {
15 pub fn new(line_index: usize, char_offset: usize) -> Self {
16 Self {
17 line_index,
18 char_offset,
19 }
20 }
21}
22
23impl PartialOrd for TextPosition {
24 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
25 Some(self.cmp(other))
26 }
27}
28
29impl Ord for TextPosition {
30 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
31 self.line_index
32 .cmp(&other.line_index)
33 .then(self.char_offset.cmp(&other.char_offset))
34 }
35}
36
37#[derive(Debug, Clone, Copy)]
39pub struct SelectionRange {
40 pub anchor: TextPosition,
42 pub cursor: TextPosition,
44}
45
46impl SelectionRange {
47 pub fn ordered(&self) -> (TextPosition, TextPosition) {
49 if self.anchor <= self.cursor {
50 (self.anchor, self.cursor)
51 } else {
52 (self.cursor, self.anchor)
53 }
54 }
55
56 pub fn contains_line(&self, line_index: usize) -> bool {
58 let (start, end) = self.ordered();
59 line_index >= start.line_index && line_index <= end.line_index
60 }
61
62 pub fn columns_on_line(&self, line_index: usize, line_width: usize) -> Option<(usize, usize)> {
65 let (start, end) = self.ordered();
66 if line_index < start.line_index || line_index > end.line_index {
67 return None;
68 }
69 let col_start = if line_index == start.line_index {
70 start.char_offset
71 } else {
72 0
73 };
74 let col_end = if line_index == end.line_index {
75 end.char_offset
76 } else {
77 line_width
78 };
79 if col_start >= col_end {
80 None
81 } else {
82 Some((col_start, col_end))
83 }
84 }
85}
86
87#[derive(Debug, Default)]
89pub struct SelectionState {
90 pub active: bool,
92 pub range: Option<SelectionRange>,
94 pub conversation_area: Rect,
96 pub actual_scroll: usize,
98 pub total_content_lines: usize,
100 pub auto_scroll_direction: Option<i8>,
102}
103
104impl SelectionState {
105 pub fn screen_to_text_position(&self, col: u16, row: u16) -> Option<TextPosition> {
110 let area = self.conversation_area;
111 if area.width == 0 || area.height == 0 {
112 return None;
113 }
114 let rel_col = col.saturating_sub(area.x) as usize;
116 let rel_row = if row < area.y {
117 0
118 } else {
119 (row - area.y) as usize
120 };
121 let line_index = self.actual_scroll + rel_row;
122 let char_offset = rel_col.min(area.width as usize);
123 Some(TextPosition::new(line_index, char_offset))
124 }
125
126 pub fn is_in_conversation_area(&self, col: u16, row: u16) -> bool {
128 let area = self.conversation_area;
129 col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height
130 }
131
132 pub fn clear(&mut self) {
134 self.active = false;
135 self.range = None;
136 self.auto_scroll_direction = None;
137 }
138
139 pub fn start(&mut self, col: u16, row: u16) {
141 if let Some(pos) = self.screen_to_text_position(col, row) {
142 self.active = true;
143 self.range = Some(SelectionRange {
144 anchor: pos,
145 cursor: pos,
146 });
147 self.auto_scroll_direction = None;
148 }
149 }
150
151 pub fn extend(&mut self, col: u16, row: u16) {
153 let area = self.conversation_area;
154
155 if row < area.y + 1 {
157 self.auto_scroll_direction = Some(-1); } else if row >= area.y + area.height.saturating_sub(1) {
159 self.auto_scroll_direction = Some(1); } else {
161 self.auto_scroll_direction = None;
162 }
163
164 if let Some(pos) = self.screen_to_text_position(col, row)
165 && let Some(ref mut range) = self.range
166 {
167 range.cursor = pos;
168 }
169 }
170
171 pub fn finalize(&mut self) -> bool {
173 self.active = false;
174 self.auto_scroll_direction = None;
175 self.range.is_some_and(|r| {
176 let (start, end) = r.ordered();
177 start != end
178 })
179 }
180}
181
182#[cfg(test)]
183#[path = "selection_tests.rs"]
184mod tests;