1use ratatui::layout::Rect;
19
20use crate::wrapcache::{PanelWrap, TextPos, WrapMode};
21
22pub fn ordered(a: TextPos, b: TextPos) -> (TextPos, TextPos) {
25 if a <= b { (a, b) } else { (b, a) }
26}
27
28pub fn point_to_textpos(point: (u16, u16), area: Rect, scroll: u16, wrap: &PanelWrap) -> TextPos {
33 let (col, row) = point;
34 let local_row = if area.height == 0 || row < area.y {
35 0
36 } else {
37 ((row - area.y) as u32).min(area.height as u32 - 1)
38 };
39 let local_col = if area.width == 0 || col < area.x {
40 0
41 } else {
42 (col - area.x) as usize
43 };
44 wrap.row_col_to_textpos(scroll as u32 + local_row, local_col)
45}
46
47fn range_for_line(line: usize, start: TextPos, end: TextPos, wrap: &PanelWrap) -> (usize, usize) {
52 let len = wrap.line_char_len(line);
53 if start.line == end.line {
54 (start.col.min(len), (end.col + 1).min(len))
55 } else if line == start.line {
56 (start.col.min(len), len)
57 } else if line == end.line {
58 (0, (end.col + 1).min(len))
59 } else {
60 (0, len)
61 }
62}
63
64fn selection_ranges(start: TextPos, end: TextPos, wrap: &PanelWrap) -> Vec<(usize, usize, usize)> {
71 let mut out = Vec::new();
72 for line in start.line..=end.line {
73 if line >= wrap.line_count() {
74 break;
75 }
76 let (from, to) = range_for_line(line, start, end, wrap);
77 out.push((line, from, to));
78 }
79 out
80}
81
82pub fn extract_text(
92 anchor: TextPos,
93 cursor: TextPos,
94 wrap: &PanelWrap,
95 exclude: Option<&std::collections::HashSet<TextPos>>,
96) -> Option<String> {
97 if wrap.line_count() == 0 {
98 return None;
99 }
100 let (start, end) = ordered(anchor, cursor);
101 let ranges = selection_ranges(start, end, wrap);
102 let mut out = String::new();
103 for (i, (line, from, to)) in ranges.iter().enumerate() {
104 if i > 0 {
105 out.push('\n');
106 }
107 let text = wrap.line_text(*line);
108 let piece: String = text
109 .chars()
110 .enumerate()
111 .skip(*from)
112 .take(to.saturating_sub(*from))
113 .filter(|(col, _)| !exclude.is_some_and(|ex| ex.contains(&TextPos::new(*line, *col))))
114 .map(|(_, c)| c)
115 .collect();
116 out.push_str(&piece);
117 }
118 if out.trim().is_empty() {
119 None
120 } else {
121 Some(out)
122 }
123}
124
125pub fn strip_positions(text: &str, exclude: &std::collections::HashSet<TextPos>) -> String {
132 if exclude.is_empty() {
133 return text.to_string();
134 }
135 let mut out = String::with_capacity(text.len());
136 for (line_idx, line) in text.lines().enumerate() {
137 if line_idx > 0 {
138 out.push('\n');
139 }
140 for (col, ch) in line.chars().enumerate() {
141 if !exclude.contains(&TextPos::new(line_idx, col)) {
142 out.push(ch);
143 }
144 }
145 }
146 if text.ends_with('\n') {
147 out.push('\n');
148 }
149 out
150}
151
152pub fn highlight_cells(
159 anchor: TextPos,
160 cursor: TextPos,
161 wrap: &PanelWrap,
162 area: Rect,
163 scroll: u16,
164) -> Vec<(u16, u16, u16)> {
165 if area.width == 0 || area.height == 0 || wrap.line_count() == 0 {
166 return Vec::new();
167 }
168 let (start, end) = ordered(anchor, cursor);
169 let first_visible = wrap.row_col_to_textpos(scroll as u32, 0).line;
170 let last_visible_row = (scroll as u32 + area.height as u32).saturating_sub(1);
171 let last_visible = wrap.row_col_to_textpos(last_visible_row, 0).line;
172 let lo = start.line.max(first_visible);
173 let hi = end.line.min(last_visible);
174 if lo > hi {
175 return Vec::new();
176 }
177 let mut out = Vec::new();
178 for line in lo..=hi {
179 if line >= wrap.line_count() {
180 break;
181 }
182 let (from, to) = range_for_line(line, start, end, wrap);
183 if from >= to {
184 continue;
185 }
186 let len = wrap.line_char_len(line);
187 let width = wrap.wrap_width();
192 let (base_row, _) = wrap.textpos_to_row_col(TextPos::new(line, 0));
193 let rows_in_line = if wrap.mode() == WrapMode::Clip || width == 0 {
195 1
197 } else {
198 len.div_ceil(width).max(1)
199 };
200 let window_lo = (scroll as u32).saturating_sub(base_row);
205 let window_hi_excl =
206 ((scroll as u32).saturating_add(area.height as u32)).saturating_sub(base_row);
207 let r_lo = window_lo.min(rows_in_line as u32) as usize;
208 let r_hi = window_hi_excl.min(rows_in_line as u32) as usize;
209 for r in r_lo..r_hi {
210 let row_start = r * width.max(1);
211 let row_end = ((r + 1) * width.max(1)).min(len);
212 let seg_from = from.max(row_start);
213 let seg_to = to.min(row_end);
214 if seg_from >= seg_to {
215 continue;
216 }
217 let abs_row = base_row + r as u32;
218 if abs_row < scroll as u32 {
219 continue;
220 }
221 let local_row = abs_row - scroll as u32;
222 if local_row >= area.height as u32 {
223 continue;
224 }
225 out.push((
226 area.y + local_row as u16,
227 area.x + (seg_from - row_start) as u16,
228 area.x + (seg_to - row_start) as u16,
229 ));
230 }
231 }
232 out
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use std::sync::Arc;
239
240 fn rect() -> Rect {
241 Rect::new(2, 1, 20, 5) }
243
244 fn wrap() -> PanelWrap {
245 PanelWrap::build(
246 Arc::from("first line here\nsecond\n\nfourth line of text\nfifth"),
247 20,
248 )
249 }
250
251 #[test]
252 fn point_to_textpos_maps_terminal_coords_into_logical_positions() {
253 let area = rect();
254 let w = wrap();
255 assert_eq!(
256 point_to_textpos((2, 1), area, 0, &w),
257 TextPos::new(0, 0),
258 "top-left of the area"
259 );
260 assert_eq!(
262 point_to_textpos((5, 2), area, 0, &w),
263 TextPos::new(1, 3),
264 "interior point offsets by area origin"
265 );
266 assert_eq!(point_to_textpos((0, 0), area, 0, &w), TextPos::new(0, 0));
268 }
269
270 #[test]
271 fn single_row_selection_takes_only_the_selected_columns() {
272 let w = wrap();
273 let text = extract_text(TextPos::new(0, 2), TextPos::new(0, 5), &w, None).unwrap();
274 assert_eq!(text, "rst "); }
276
277 #[test]
278 fn multi_row_selection_takes_the_rest_of_the_first_line_full_middle_lines_and_the_start_of_the_last()
279 {
280 let w = wrap();
281 let text = extract_text(TextPos::new(0, 6), TextPos::new(3, 5), &w, None).unwrap();
282 assert_eq!(text, "line here\nsecond\n\nfourth");
283 }
284
285 #[test]
286 fn dragging_backwards_still_resolves_to_the_same_selection() {
287 let w = wrap();
288 let forward = extract_text(TextPos::new(0, 2), TextPos::new(1, 4), &w, None).unwrap();
289 let backward = extract_text(TextPos::new(1, 4), TextPos::new(0, 2), &w, None).unwrap();
290 assert_eq!(forward, backward);
291 }
292
293 #[test]
294 fn a_blank_or_empty_selection_extracts_to_none() {
295 let w = wrap();
296 assert_eq!(
300 extract_text(TextPos::new(2, 0), TextPos::new(2, 0), &w, None),
301 None
302 );
303 let empty = PanelWrap::build(Arc::from(""), 20);
304 assert_eq!(
305 extract_text(TextPos::new(0, 0), TextPos::new(0, 0), &empty, None),
306 None,
307 "no content"
308 );
309 }
310
311 #[test]
312 fn extract_text_excludes_only_the_positions_given() {
313 let w = wrap();
314 let mut exclude = std::collections::HashSet::new();
315 exclude.insert(TextPos::new(0, 0));
317 let text =
318 extract_text(TextPos::new(0, 0), TextPos::new(0, 5), &w, Some(&exclude)).unwrap();
319 assert_eq!(
320 text, "irst ",
321 "the excluded column is dropped, all others are kept"
322 );
323 }
324
325 #[test]
326 fn strip_positions_removes_only_excluded_characters() {
327 let mut exclude = std::collections::HashSet::new();
328 exclude.insert(TextPos::new(0, 5)); let out = strip_positions("hello!world\nsecond!line", &exclude);
330 assert_eq!(
331 out, "helloworld\nsecond!line",
332 "only the recorded position is stripped, other lines untouched"
333 );
334 }
335
336 #[test]
337 fn strip_positions_is_a_no_op_with_an_empty_exclude_set() {
338 let exclude = std::collections::HashSet::new();
339 let out = strip_positions("unchanged!text\n", &exclude);
340 assert_eq!(out, "unchanged!text\n");
341 }
342
343 #[test]
344 fn highlight_cells_skip_empty_rows_and_report_absolute_terminal_columns() {
345 let w = wrap();
346 let area = rect();
347 let cells = highlight_cells(TextPos::new(0, 6), TextPos::new(3, 5), &w, area, 0);
348 assert_eq!(
350 cells,
351 vec![
352 (area.y, area.x + 6, area.x + 15),
353 (area.y + 1, area.x, area.x + 6),
354 (area.y + 3, area.x, area.x + 6),
355 ]
356 );
357 }
358
359 #[test]
360 fn highlight_cells_only_scans_lines_intersecting_the_visible_window() {
361 let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
365 let w = PanelWrap::build(Arc::from(body), 20);
366 let area = Rect::new(0, 0, 20, 5);
367 let cells = highlight_cells(
368 TextPos::new(0, 0),
369 TextPos::new(99_999, 3),
370 &w,
371 area,
372 50_000,
373 );
374 assert_eq!(
375 cells.len(),
376 5,
377 "exactly the 5 visible rows, not the whole selected range"
378 );
379 assert_eq!(cells[0].0, 0);
380 assert_eq!(cells[4].0, 4);
381 }
382
383 #[test]
384 fn highlight_cells_handles_a_selection_wholly_off_screen() {
385 let w = wrap();
386 let area = rect();
387 let cells = highlight_cells(TextPos::new(0, 0), TextPos::new(0, 3), &w, area, 10);
389 assert!(cells.is_empty());
390 }
391
392 #[test]
399 fn highlight_cells_bounds_the_scan_even_when_one_line_has_thousands_of_wrapped_rows() {
400 let body: String = "x".repeat(500_000); let w = PanelWrap::build(Arc::from(body), 20);
402 let area = Rect::new(0, 0, 20, 5);
403 let cells = highlight_cells(
405 TextPos::new(0, 0),
406 TextPos::new(0, 499_999),
407 &w,
408 area,
409 12_000,
410 );
411 assert_eq!(
412 cells.len(),
413 5,
414 "exactly the 5 visible rows of this one giant line, not all 25,000"
415 );
416 assert_eq!(cells[0].0, area.y);
417 assert_eq!(cells[4].0, area.y + 4);
418 for &(_, from, to) in &cells {
420 assert_eq!(
421 to - from,
422 area.width,
423 "each visible row of a fully-selected giant line is fully highlighted"
424 );
425 }
426 }
427}