Skip to main content

imp_tui/
selection.rs

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}