1use ratatui::buffer::Buffer;
2use ratatui::layout::Rect;
3use ratatui::widgets::Widget;
4
5use crate::theme::Theme;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SelectablePane {
9 Chat,
10 SidebarDetail,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FocusPane {
15 Chat,
16 SidebarList,
17 SidebarDetail,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct SelectionPos {
22 pub line: usize,
23 pub col: usize,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SelectionState {
28 pub pane: SelectablePane,
29 pub anchor: SelectionPos,
30 pub focus: SelectionPos,
31}
32
33impl SelectionState {
34 pub fn new(pane: SelectablePane, anchor: SelectionPos, focus: SelectionPos) -> Self {
35 Self {
36 pane,
37 anchor,
38 focus,
39 }
40 }
41
42 pub fn normalized(&self) -> (SelectionPos, SelectionPos) {
43 if (self.anchor.line, self.anchor.col) <= (self.focus.line, self.focus.col) {
44 (self.anchor, self.focus)
45 } else {
46 (self.focus, self.anchor)
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct TextSurface {
53 pub pane: SelectablePane,
54 pub rect: Rect,
55 pub lines: Vec<String>,
56 pub top_line: usize,
57}
58
59impl TextSurface {
60 pub fn new(pane: SelectablePane, rect: Rect, lines: Vec<String>, top_line: usize) -> Self {
61 Self {
62 pane,
63 rect,
64 lines,
65 top_line,
66 }
67 }
68
69 pub fn contains(&self, col: u16, row: u16) -> bool {
70 self.rect.width > 0
71 && self.rect.height > 0
72 && col >= self.rect.x
73 && col < self.rect.x + self.rect.width
74 && row >= self.rect.y
75 && row < self.rect.y + self.rect.height
76 }
77
78 pub fn is_empty(&self) -> bool {
79 self.lines.is_empty()
80 }
81
82 pub fn default_pos(&self) -> SelectionPos {
83 SelectionPos {
84 line: self.top_line.min(self.lines.len().saturating_sub(1)),
85 col: 0,
86 }
87 }
88
89 pub fn line_len(&self, line: usize) -> usize {
90 self.lines.get(line).map(|s| s.chars().count()).unwrap_or(0)
91 }
92
93 pub fn clamp_pos(&self, mut pos: SelectionPos) -> SelectionPos {
94 if self.lines.is_empty() {
95 return SelectionPos { line: 0, col: 0 };
96 }
97
98 pos.line = pos.line.min(self.lines.len().saturating_sub(1));
99 let len = self.line_len(pos.line);
100 pos.col = if len == 0 { 0 } else { pos.col.min(len - 1) };
101 pos
102 }
103
104 pub fn pos_from_screen_clamped(&self, col: u16, row: u16) -> SelectionPos {
105 if self.lines.is_empty() {
106 return SelectionPos { line: 0, col: 0 };
107 }
108
109 let clamped_row = row.clamp(
110 self.rect.y,
111 self.rect.y + self.rect.height.saturating_sub(1),
112 );
113 let visible_line = (clamped_row - self.rect.y) as usize;
114 let line = (self.top_line + visible_line).min(self.lines.len().saturating_sub(1));
115
116 let line_len = self.line_len(line);
117 let relative_col = col.saturating_sub(self.rect.x) as usize;
118 let bounded_col = if line_len == 0 {
119 0
120 } else {
121 relative_col.min(line_len - 1)
122 };
123
124 SelectionPos {
125 line,
126 col: bounded_col,
127 }
128 }
129
130 pub fn visible_row_for_line(&self, line: usize) -> Option<u16> {
131 if line < self.top_line {
132 return None;
133 }
134 let rel = line - self.top_line;
135 if rel >= self.rect.height as usize {
136 None
137 } else {
138 Some(self.rect.y + rel as u16)
139 }
140 }
141
142 pub fn move_pos(&self, pos: SelectionPos, line_delta: isize, col_delta: isize) -> SelectionPos {
143 if self.lines.is_empty() {
144 return SelectionPos { line: 0, col: 0 };
145 }
146
147 let max_line = self.lines.len().saturating_sub(1) as isize;
148 let next_line = (pos.line as isize + line_delta).clamp(0, max_line) as usize;
149 let desired_col = if col_delta.is_negative() {
150 pos.col.saturating_sub(col_delta.unsigned_abs())
151 } else {
152 pos.col.saturating_add(col_delta as usize)
153 };
154
155 self.clamp_pos(SelectionPos {
156 line: next_line,
157 col: desired_col,
158 })
159 }
160}
161
162pub fn extract_selected_text(surface: &TextSurface, selection: &SelectionState) -> Option<String> {
163 if selection.pane != surface.pane || surface.lines.is_empty() {
164 return None;
165 }
166
167 let (start, end) = selection.normalized();
168 let start = surface.clamp_pos(start);
169 let end = surface.clamp_pos(end);
170
171 let mut out = String::new();
172 for line_idx in start.line..=end.line {
173 let line = surface.lines.get(line_idx)?;
174 let chars: Vec<char> = line.chars().collect();
175
176 let slice = if chars.is_empty() {
177 String::new()
178 } else if start.line == end.line {
179 chars[start.col..=end.col].iter().collect()
180 } else if line_idx == start.line {
181 chars[start.col..].iter().collect()
182 } else if line_idx == end.line {
183 chars[..=end.col].iter().collect()
184 } else {
185 chars.iter().collect()
186 };
187
188 out.push_str(&slice);
189 if line_idx != end.line {
190 out.push('\n');
191 }
192 }
193
194 Some(out)
195}
196
197pub struct SelectionOverlay<'a> {
198 theme: &'a Theme,
199 selection: Option<&'a SelectionState>,
200 chat_surface: Option<&'a TextSurface>,
201 sidebar_surface: Option<&'a TextSurface>,
202}
203
204impl<'a> SelectionOverlay<'a> {
205 pub fn new(
206 theme: &'a Theme,
207 selection: Option<&'a SelectionState>,
208 chat_surface: Option<&'a TextSurface>,
209 sidebar_surface: Option<&'a TextSurface>,
210 ) -> Self {
211 Self {
212 theme,
213 selection,
214 chat_surface,
215 sidebar_surface,
216 }
217 }
218
219 fn surface_for_selection(&self) -> Option<&TextSurface> {
220 let selection = self.selection?;
221 match selection.pane {
222 SelectablePane::Chat => self.chat_surface,
223 SelectablePane::SidebarDetail => self.sidebar_surface,
224 }
225 }
226}
227
228impl Widget for SelectionOverlay<'_> {
229 fn render(self, _area: Rect, buf: &mut Buffer) {
230 let Some(selection) = self.selection else {
231 return;
232 };
233 let Some(surface) = self.surface_for_selection() else {
234 return;
235 };
236 if surface.lines.is_empty() {
237 return;
238 }
239
240 let (start, end) = selection.normalized();
241 let start = surface.clamp_pos(start);
242 let end = surface.clamp_pos(end);
243 let style = self.theme.selected_style();
244
245 for line_idx in start.line..=end.line {
246 let Some(row) = surface.visible_row_for_line(line_idx) else {
247 continue;
248 };
249 let line_len = surface.line_len(line_idx);
250 if line_len == 0 {
251 continue;
252 }
253
254 let start_col = if line_idx == start.line { start.col } else { 0 };
255 let end_col = if line_idx == end.line {
256 end.col.min(line_len.saturating_sub(1))
257 } else {
258 line_len.saturating_sub(1)
259 };
260
261 for col in start_col..=end_col {
262 let x = surface.rect.x + col as u16;
263 if x >= surface.rect.x + surface.rect.width {
264 break;
265 }
266 if let Some(cell) = buf.cell_mut((x, row)) {
267 cell.set_style(style);
268 }
269 }
270 }
271 }
272}