1use ratatui::layout::Rect;
19
20use crate::wrapcache::{PanelWrap, TextPos};
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 = area.width as usize;
188 let (base_row, _) = wrap.textpos_to_row_col(TextPos::new(line, 0));
189 let rows_in_line = if width == 0 {
190 1
191 } else {
192 len.div_ceil(width).max(1)
193 };
194 let window_lo = (scroll as u32).saturating_sub(base_row);
199 let window_hi_excl =
200 ((scroll as u32).saturating_add(area.height as u32)).saturating_sub(base_row);
201 let r_lo = window_lo.min(rows_in_line as u32) as usize;
202 let r_hi = window_hi_excl.min(rows_in_line as u32) as usize;
203 for r in r_lo..r_hi {
204 let row_start = r * width.max(1);
205 let row_end = ((r + 1) * width.max(1)).min(len);
206 let seg_from = from.max(row_start);
207 let seg_to = to.min(row_end);
208 if seg_from >= seg_to {
209 continue;
210 }
211 let abs_row = base_row + r as u32;
212 if abs_row < scroll as u32 {
213 continue;
214 }
215 let local_row = abs_row - scroll as u32;
216 if local_row >= area.height as u32 {
217 continue;
218 }
219 out.push((
220 area.y + local_row as u16,
221 area.x + (seg_from - row_start) as u16,
222 area.x + (seg_to - row_start) as u16,
223 ));
224 }
225 }
226 out
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::sync::Arc;
233
234 fn rect() -> Rect {
235 Rect::new(2, 1, 20, 5) }
237
238 fn wrap() -> PanelWrap {
239 PanelWrap::build(
240 Arc::from("first line here\nsecond\n\nfourth line of text\nfifth"),
241 20,
242 )
243 }
244
245 #[test]
246 fn point_to_textpos_maps_terminal_coords_into_logical_positions() {
247 let area = rect();
248 let w = wrap();
249 assert_eq!(
250 point_to_textpos((2, 1), area, 0, &w),
251 TextPos::new(0, 0),
252 "top-left of the area"
253 );
254 assert_eq!(
256 point_to_textpos((5, 2), area, 0, &w),
257 TextPos::new(1, 3),
258 "interior point offsets by area origin"
259 );
260 assert_eq!(point_to_textpos((0, 0), area, 0, &w), TextPos::new(0, 0));
262 }
263
264 #[test]
265 fn single_row_selection_takes_only_the_selected_columns() {
266 let w = wrap();
267 let text = extract_text(TextPos::new(0, 2), TextPos::new(0, 5), &w, None).unwrap();
268 assert_eq!(text, "rst "); }
270
271 #[test]
272 fn multi_row_selection_takes_the_rest_of_the_first_line_full_middle_lines_and_the_start_of_the_last()
273 {
274 let w = wrap();
275 let text = extract_text(TextPos::new(0, 6), TextPos::new(3, 5), &w, None).unwrap();
276 assert_eq!(text, "line here\nsecond\n\nfourth");
277 }
278
279 #[test]
280 fn dragging_backwards_still_resolves_to_the_same_selection() {
281 let w = wrap();
282 let forward = extract_text(TextPos::new(0, 2), TextPos::new(1, 4), &w, None).unwrap();
283 let backward = extract_text(TextPos::new(1, 4), TextPos::new(0, 2), &w, None).unwrap();
284 assert_eq!(forward, backward);
285 }
286
287 #[test]
288 fn a_blank_or_empty_selection_extracts_to_none() {
289 let w = wrap();
290 assert_eq!(
294 extract_text(TextPos::new(2, 0), TextPos::new(2, 0), &w, None),
295 None
296 );
297 let empty = PanelWrap::build(Arc::from(""), 20);
298 assert_eq!(
299 extract_text(TextPos::new(0, 0), TextPos::new(0, 0), &empty, None),
300 None,
301 "no content"
302 );
303 }
304
305 #[test]
306 fn extract_text_excludes_only_the_positions_given() {
307 let w = wrap();
308 let mut exclude = std::collections::HashSet::new();
309 exclude.insert(TextPos::new(0, 0));
311 let text =
312 extract_text(TextPos::new(0, 0), TextPos::new(0, 5), &w, Some(&exclude)).unwrap();
313 assert_eq!(
314 text, "irst ",
315 "the excluded column is dropped, all others are kept"
316 );
317 }
318
319 #[test]
320 fn strip_positions_removes_only_excluded_characters() {
321 let mut exclude = std::collections::HashSet::new();
322 exclude.insert(TextPos::new(0, 5)); let out = strip_positions("hello!world\nsecond!line", &exclude);
324 assert_eq!(
325 out, "helloworld\nsecond!line",
326 "only the recorded position is stripped, other lines untouched"
327 );
328 }
329
330 #[test]
331 fn strip_positions_is_a_no_op_with_an_empty_exclude_set() {
332 let exclude = std::collections::HashSet::new();
333 let out = strip_positions("unchanged!text\n", &exclude);
334 assert_eq!(out, "unchanged!text\n");
335 }
336
337 #[test]
338 fn highlight_cells_skip_empty_rows_and_report_absolute_terminal_columns() {
339 let w = wrap();
340 let area = rect();
341 let cells = highlight_cells(TextPos::new(0, 6), TextPos::new(3, 5), &w, area, 0);
342 assert_eq!(
344 cells,
345 vec![
346 (area.y, area.x + 6, area.x + 15),
347 (area.y + 1, area.x, area.x + 6),
348 (area.y + 3, area.x, area.x + 6),
349 ]
350 );
351 }
352
353 #[test]
354 fn highlight_cells_only_scans_lines_intersecting_the_visible_window() {
355 let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
359 let w = PanelWrap::build(Arc::from(body), 20);
360 let area = Rect::new(0, 0, 20, 5);
361 let cells = highlight_cells(
362 TextPos::new(0, 0),
363 TextPos::new(99_999, 3),
364 &w,
365 area,
366 50_000,
367 );
368 assert_eq!(
369 cells.len(),
370 5,
371 "exactly the 5 visible rows, not the whole selected range"
372 );
373 assert_eq!(cells[0].0, 0);
374 assert_eq!(cells[4].0, 4);
375 }
376
377 #[test]
378 fn highlight_cells_handles_a_selection_wholly_off_screen() {
379 let w = wrap();
380 let area = rect();
381 let cells = highlight_cells(TextPos::new(0, 0), TextPos::new(0, 3), &w, area, 10);
383 assert!(cells.is_empty());
384 }
385
386 #[test]
393 fn highlight_cells_bounds_the_scan_even_when_one_line_has_thousands_of_wrapped_rows() {
394 let body: String = "x".repeat(500_000); let w = PanelWrap::build(Arc::from(body), 20);
396 let area = Rect::new(0, 0, 20, 5);
397 let cells = highlight_cells(
399 TextPos::new(0, 0),
400 TextPos::new(0, 499_999),
401 &w,
402 area,
403 12_000,
404 );
405 assert_eq!(
406 cells.len(),
407 5,
408 "exactly the 5 visible rows of this one giant line, not all 25,000"
409 );
410 assert_eq!(cells[0].0, area.y);
411 assert_eq!(cells[4].0, area.y + 4);
412 for &(_, from, to) in &cells {
414 assert_eq!(
415 to - from,
416 area.width,
417 "each visible row of a fully-selected giant line is fully highlighted"
418 );
419 }
420 }
421}