Skip to main content

branchdiff/ui/
selection.rs

1use ratatui::text::Span;
2
3use crate::app::Selection;
4
5pub const SELECTION_BG_COLOR: ratatui::style::Color = ratatui::style::Color::Rgb(60, 60, 100);
6
7/// Get the selection range for a specific line (start_col, end_col)
8/// Returns None if the line is not selected
9pub fn get_line_selection_range(selection: &Option<Selection>, line_idx: usize) -> Option<(usize, usize)> {
10    let sel = selection.as_ref()?;
11
12    // Normalize selection (start should be before end)
13    let (start, end) = if sel.start.row < sel.end.row
14        || (sel.start.row == sel.end.row && sel.start.col <= sel.end.col)
15    {
16        (sel.start, sel.end)
17    } else {
18        (sel.end, sel.start)
19    };
20
21    // Check if this line is within selection
22    if line_idx < start.row || line_idx > end.row {
23        return None;
24    }
25
26    // Determine start and end columns for this line
27    let start_col = if line_idx == start.row { start.col } else { 0 };
28    let end_col = if line_idx == end.row { end.col } else { usize::MAX };
29
30    Some((start_col, end_col))
31}
32
33/// Apply selection highlighting to a span, splitting it if partially selected
34pub fn apply_selection_to_span(
35    span: Span<'static>,
36    char_offset: usize,
37    sel_start: usize,
38    sel_end: usize,
39) -> Vec<Span<'static>> {
40    let text = span.content.to_string();
41    let text_len = text.len();
42    let text_start = char_offset;
43    let text_end = char_offset + text_len;
44
45    // Check if there's any overlap with selection
46    if text_end <= sel_start || text_start >= sel_end {
47        // No overlap - return original span
48        return vec![span];
49    }
50
51    let mut result = Vec::new();
52    let base_style = span.style;
53    let selected_style = base_style.bg(SELECTION_BG_COLOR);
54
55    // Before selection
56    if text_start < sel_start {
57        let before_end = (sel_start - text_start).min(text_len);
58        let before_text: String = text.chars().take(before_end).collect();
59        if !before_text.is_empty() {
60            result.push(Span::styled(before_text, base_style));
61        }
62    }
63
64    // Selected portion
65    let sel_in_text_start = sel_start.saturating_sub(text_start);
66    let sel_in_text_end = (sel_end - text_start).min(text_len);
67    if sel_in_text_start < sel_in_text_end {
68        let selected_text: String = text.chars()
69            .skip(sel_in_text_start)
70            .take(sel_in_text_end - sel_in_text_start)
71            .collect();
72        if !selected_text.is_empty() {
73            result.push(Span::styled(selected_text, selected_style));
74        }
75    }
76
77    // After selection
78    if text_end > sel_end {
79        let after_start = sel_end.saturating_sub(text_start);
80        let after_text: String = text.chars().skip(after_start).collect();
81        if !after_text.is_empty() {
82            result.push(Span::styled(after_text, base_style));
83        }
84    }
85
86    if result.is_empty() {
87        vec![span]
88    } else {
89        result
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::app::{Position, Selection};
97    use ratatui::style::Style;
98
99    fn selection(start_row: usize, start_col: usize, end_row: usize, end_col: usize) -> Selection {
100        Selection {
101            start: Position { row: start_row, col: start_col },
102            end: Position { row: end_row, col: end_col },
103            active: true,
104        }
105    }
106
107    // ===== get_line_selection_range tests =====
108
109    #[test]
110    fn line_selection_returns_none_when_no_selection() {
111        assert_eq!(get_line_selection_range(&None, 5), None);
112    }
113
114    #[test]
115    fn line_selection_returns_none_when_line_before_selection() {
116        let sel = selection(5, 0, 10, 0);
117        assert_eq!(get_line_selection_range(&Some(sel), 3), None);
118    }
119
120    #[test]
121    fn line_selection_returns_none_when_line_after_selection() {
122        let sel = selection(5, 0, 10, 0);
123        assert_eq!(get_line_selection_range(&Some(sel), 15), None);
124    }
125
126    #[test]
127    fn line_selection_single_line_returns_exact_columns() {
128        let sel = selection(5, 3, 5, 10);
129        assert_eq!(get_line_selection_range(&Some(sel), 5), Some((3, 10)));
130    }
131
132    #[test]
133    fn line_selection_normalizes_backwards_selection() {
134        // Selection dragged backwards: end is before start
135        let sel = selection(5, 10, 5, 3);
136        assert_eq!(get_line_selection_range(&Some(sel), 5), Some((3, 10)));
137    }
138
139    #[test]
140    fn line_selection_first_line_of_multiline() {
141        let sel = selection(5, 8, 10, 4);
142        // First line: from start.col to end of line
143        assert_eq!(get_line_selection_range(&Some(sel), 5), Some((8, usize::MAX)));
144    }
145
146    #[test]
147    fn line_selection_last_line_of_multiline() {
148        let sel = selection(5, 8, 10, 4);
149        // Last line: from beginning to end.col
150        assert_eq!(get_line_selection_range(&Some(sel), 10), Some((0, 4)));
151    }
152
153    #[test]
154    fn line_selection_middle_line_of_multiline() {
155        let sel = selection(5, 8, 10, 4);
156        // Middle lines: entire line selected
157        assert_eq!(get_line_selection_range(&Some(sel), 7), Some((0, usize::MAX)));
158    }
159
160    // ===== apply_selection_to_span tests =====
161
162    #[test]
163    fn span_no_overlap_before_selection() {
164        let span = Span::styled("hello", Style::default());
165        // Span at offset 0-5, selection at 10-15
166        let result = apply_selection_to_span(span.clone(), 0, 10, 15);
167        assert_eq!(result.len(), 1);
168        assert_eq!(result[0].content, "hello");
169    }
170
171    #[test]
172    fn span_no_overlap_after_selection() {
173        let span = Span::styled("hello", Style::default());
174        // Span at offset 20-25, selection at 10-15
175        let result = apply_selection_to_span(span.clone(), 20, 10, 15);
176        assert_eq!(result.len(), 1);
177        assert_eq!(result[0].content, "hello");
178    }
179
180    #[test]
181    fn span_fully_inside_selection() {
182        let span = Span::styled("hello", Style::default());
183        // Span at offset 10-15, selection at 5-20 (fully contains span)
184        let result = apply_selection_to_span(span, 10, 5, 20);
185        assert_eq!(result.len(), 1);
186        assert_eq!(result[0].content, "hello");
187        assert_eq!(result[0].style.bg, Some(SELECTION_BG_COLOR));
188    }
189
190    #[test]
191    fn span_selection_at_start() {
192        let span = Span::styled("hello", Style::default());
193        // Span at offset 0, selection covers first 2 chars
194        let result = apply_selection_to_span(span, 0, 0, 2);
195        assert_eq!(result.len(), 2);
196        assert_eq!(result[0].content, "he");
197        assert_eq!(result[0].style.bg, Some(SELECTION_BG_COLOR));
198        assert_eq!(result[1].content, "llo");
199        assert_eq!(result[1].style.bg, None);
200    }
201
202    #[test]
203    fn span_selection_at_end() {
204        let span = Span::styled("hello", Style::default());
205        // Span at offset 0, selection covers last 2 chars
206        let result = apply_selection_to_span(span, 0, 3, 10);
207        assert_eq!(result.len(), 2);
208        assert_eq!(result[0].content, "hel");
209        assert_eq!(result[0].style.bg, None);
210        assert_eq!(result[1].content, "lo");
211        assert_eq!(result[1].style.bg, Some(SELECTION_BG_COLOR));
212    }
213
214    #[test]
215    fn span_selection_in_middle() {
216        let span = Span::styled("hello", Style::default());
217        // Span at offset 0, selection covers middle chars
218        let result = apply_selection_to_span(span, 0, 1, 4);
219        assert_eq!(result.len(), 3);
220        assert_eq!(result[0].content, "h");
221        assert_eq!(result[0].style.bg, None);
222        assert_eq!(result[1].content, "ell");
223        assert_eq!(result[1].style.bg, Some(SELECTION_BG_COLOR));
224        assert_eq!(result[2].content, "o");
225        assert_eq!(result[2].style.bg, None);
226    }
227
228    #[test]
229    fn span_with_char_offset() {
230        let span = Span::styled("world", Style::default());
231        // Span starts at offset 10, selection is 12-14 (chars 2-4 of span)
232        let result = apply_selection_to_span(span, 10, 12, 14);
233        assert_eq!(result.len(), 3);
234        assert_eq!(result[0].content, "wo");
235        assert_eq!(result[1].content, "rl");
236        assert_eq!(result[1].style.bg, Some(SELECTION_BG_COLOR));
237        assert_eq!(result[2].content, "d");
238    }
239
240    // ===== Additional edge case tests =====
241
242    #[test]
243    fn span_empty_string() {
244        let span = Span::styled("", Style::default());
245        let result = apply_selection_to_span(span, 0, 0, 10);
246        // Empty span should return original
247        assert_eq!(result.len(), 1);
248        assert_eq!(result[0].content, "");
249    }
250
251    #[test]
252    fn span_unicode_content() {
253        // Test that unicode content is handled correctly
254        // apply_selection_to_span uses CHARACTER positions (via .chars()), not byte positions
255        // "héllo wörld" = 11 characters: h é l l o (space) w ö r l d
256        let span = Span::styled("héllo wörld", Style::default());
257
258        // Select the entire string (all 11 characters)
259        let result = apply_selection_to_span(span.clone(), 0, 0, 11);
260        assert_eq!(result.len(), 1, "Entire string should be one selected span");
261        assert_eq!(result[0].content, "héllo wörld");
262        assert_eq!(result[0].style.bg, Some(SELECTION_BG_COLOR));
263
264        // Select first 5 characters: "héllo"
265        let span2 = Span::styled("héllo wörld", Style::default());
266        let result2 = apply_selection_to_span(span2, 0, 0, 5);
267        assert_eq!(result2.len(), 2, "Should split into selected and unselected");
268        assert_eq!(result2[0].content, "héllo");
269        assert_eq!(result2[0].style.bg, Some(SELECTION_BG_COLOR));
270        assert_eq!(result2[1].content, " wörld");
271
272        // Verify multi-byte characters work in the middle too
273        let span3 = Span::styled("héllo wörld", Style::default());
274        let result3 = apply_selection_to_span(span3, 0, 6, 9); // "wör"
275        assert_eq!(result3.len(), 3);
276        assert_eq!(result3[0].content, "héllo ");
277        assert_eq!(result3[1].content, "wör");
278        assert_eq!(result3[1].style.bg, Some(SELECTION_BG_COLOR));
279        assert_eq!(result3[2].content, "ld");
280    }
281
282    #[test]
283    fn span_selection_exact_boundaries() {
284        let span = Span::styled("hello", Style::default());
285        // Selection exactly matches span boundaries
286        let result = apply_selection_to_span(span, 5, 5, 10);
287        assert_eq!(result.len(), 1);
288        assert_eq!(result[0].content, "hello");
289        assert_eq!(result[0].style.bg, Some(SELECTION_BG_COLOR));
290    }
291
292    #[test]
293    fn line_selection_normalizes_multiline_backwards() {
294        // Selection dragged backwards across multiple lines
295        let sel = selection(10, 4, 5, 8);
296        // Line 7 should still be fully selected (middle line)
297        assert_eq!(get_line_selection_range(&Some(sel.clone()), 7), Some((0, usize::MAX)));
298        // Start line (after normalization, this is row 5)
299        assert_eq!(get_line_selection_range(&Some(sel.clone()), 5), Some((8, usize::MAX)));
300        // End line (after normalization, this is row 10)
301        assert_eq!(get_line_selection_range(&Some(sel), 10), Some((0, 4)));
302    }
303
304    #[test]
305    fn line_selection_at_boundary() {
306        // Selection starts and ends at exact line boundaries
307        let sel = selection(5, 0, 5, 0);
308        // Zero-width selection at start of line
309        assert_eq!(get_line_selection_range(&Some(sel), 5), Some((0, 0)));
310    }
311
312    #[test]
313    fn span_preserves_original_style_fg() {
314        use ratatui::style::Color;
315        let base_style = Style::default().fg(Color::Red);
316        let span = Span::styled("hello", base_style);
317        let result = apply_selection_to_span(span, 0, 0, 5);
318        // Selected span should keep fg color but add selection bg
319        assert_eq!(result[0].style.fg, Some(Color::Red));
320        assert_eq!(result[0].style.bg, Some(SELECTION_BG_COLOR));
321    }
322
323    #[test]
324    fn span_single_char_selection() {
325        let span = Span::styled("hello", Style::default());
326        let result = apply_selection_to_span(span, 0, 2, 3);
327        assert_eq!(result.len(), 3);
328        assert_eq!(result[0].content, "he");
329        assert_eq!(result[1].content, "l");
330        assert_eq!(result[1].style.bg, Some(SELECTION_BG_COLOR));
331        assert_eq!(result[2].content, "lo");
332    }
333}