1use cli_boilerplate_automation::bring::StrExt;
2use ratatui::{
3 layout::{Alignment, Rect},
4 style::{Style, Stylize},
5 widgets::{Paragraph, Row, Table},
6};
7use unicode_width::UnicodeWidthStr;
8
9use crate::{
10 SSS, Selection, Selector,
11 config::{HorizontalSeparator, ResultsConfig, RowConnectionStyle, StatusConfig},
12 nucleo::{Status, Worker},
13 render::Click,
14 utils::{
15 string::{fit_width, substitute_escaped},
16 text::{clip_text_lines, prefix_text},
17 },
18};
19
20#[derive(Debug)]
21pub struct ResultsUI {
22 cursor: u16,
23 bottom: u16,
24 height: u16, width: u16,
26 widths: Vec<u16>,
29 col: Option<usize>,
30 pub status: Status,
31 pub config: ResultsConfig,
32 pub status_config: StatusConfig,
33
34 pub bottom_clip: Option<u16>,
35 pub cursor_above: u16,
36
37 pub cursor_disabled: bool,
38}
39
40impl ResultsUI {
41 pub fn new(config: ResultsConfig, status_config: StatusConfig) -> Self {
42 Self {
43 cursor: 0,
44 bottom: 0,
45 col: None,
46 widths: Vec::new(),
47 status: Default::default(),
48 height: 0, width: 0,
50 config,
51 status_config,
52 cursor_disabled: false,
53 bottom_clip: None,
54 cursor_above: 0,
55 }
56 }
57 pub fn update_dimensions(&mut self, area: &Rect) {
59 let [bw, bh] = [self.config.border.height(), self.config.border.width()];
60 self.width = area.width.saturating_sub(bw);
61 self.height = area.height.saturating_sub(bh);
62 log::debug!("Updated results dimensions: {}x{}", self.width, self.height);
63 }
64
65 pub fn table_width(&self) -> u16 {
66 self.config.column_spacing.0 * self.widths().len().saturating_sub(1) as u16
67 + self.widths.iter().sum::<u16>()
68 + self.config.border.width()
69 }
70
71 pub fn reverse(&self) -> bool {
73 self.config.reverse == Some(true)
74 }
75 pub fn is_wrap(&self) -> bool {
76 self.config.wrap
77 }
78 pub fn wrap(&mut self, wrap: bool) {
79 self.config.wrap = wrap;
80 }
81
82 pub fn toggle_col(&mut self, col_idx: usize) -> bool {
85 if self.col == Some(col_idx) {
86 self.col = None
87 } else {
88 self.col = Some(col_idx);
89 }
90 self.col.is_some()
91 }
92 pub fn cycle_col(&mut self) {
93 self.col = match self.col {
94 None => self.widths.is_empty().then_some(0),
95 Some(c) => {
96 let next = c + 1;
97 if next < self.widths.len() {
98 Some(next)
99 } else {
100 None
101 }
102 }
103 };
104 }
105
106 fn scroll_padding(&self) -> u16 {
108 self.config.scroll_padding.min(self.height / 2)
109 }
110 pub fn end(&self) -> u32 {
111 self.status.matched_count.saturating_sub(1)
112 }
113
114 pub fn index(&self) -> u32 {
118 if self.cursor_disabled {
119 u32::MAX
120 } else {
121 (self.cursor + self.bottom) as u32
122 }
123 }
124 pub fn cursor_prev(&mut self) {
132 if self.cursor_above <= self.scroll_padding() && self.bottom > 0 {
133 self.bottom -= 1;
134 self.bottom_clip = None;
135 } else if self.cursor > 0 {
136 self.cursor -= 1;
137 } else if self.config.scroll_wrap {
138 self.cursor_jump(self.end());
139 }
140 }
141 pub fn cursor_next(&mut self) {
142 if self.cursor_disabled {
143 self.cursor_disabled = false
144 }
145
146 if self.cursor + 1 + self.scroll_padding() >= self.height
153 && self.bottom + self.height < self.status.matched_count as u16
154 {
155 self.bottom += 1; } else if self.index() < self.end() {
157 self.cursor += 1;
158 } else if self.config.scroll_wrap {
159 self.cursor_jump(0)
160 }
161 }
162
163 pub fn cursor_jump(&mut self, index: u32) {
164 self.cursor_disabled = false;
165 self.bottom_clip = None;
166
167 let end = self.end();
168 let index = index.min(end) as u16;
169
170 if index < self.bottom || index >= self.bottom + self.height {
171 self.bottom = (end as u16 + 1)
172 .saturating_sub(self.height) .min(index);
174 self.cursor = index - self.bottom;
175 } else {
176 self.cursor = index - self.bottom;
177 }
178 }
179
180 pub fn indentation(&self) -> usize {
182 self.config.multi_prefix.width()
183 }
184 pub fn col(&self) -> Option<usize> {
185 self.col
186 }
187
188 pub fn widths(&self) -> &Vec<u16> {
191 &self.widths
192 }
193 pub fn width(&self) -> u16 {
195 self.width.saturating_sub(self.indentation() as u16)
196 }
197
198 pub fn max_widths(&self) -> Vec<u16> {
200 if !self.config.wrap {
201 return vec![];
202 }
203
204 let mut widths = vec![u16::MAX; self.widths.len()];
205
206 let total: u16 = self.widths.iter().sum();
207 if total <= self.width() {
208 return vec![];
209 }
210
211 let mut available = self.width();
212 let mut scale_total = 0;
213 let mut scalable_indices = Vec::new();
214
215 for (i, &w) in self.widths.iter().enumerate() {
216 if w <= self.config.min_wrap_width {
217 available = available.saturating_sub(w);
218 } else {
219 scale_total += w;
220 scalable_indices.push(i);
221 }
222 }
223
224 for &i in &scalable_indices {
225 let old = self.widths[i];
226 let new_w = old * available / scale_total;
227 widths[i] = new_w.max(self.config.min_wrap_width);
228 }
229
230 if let Some(&last_idx) = scalable_indices.last() {
232 let used_total: u16 = widths.iter().sum();
233 if used_total < self.width() {
234 widths[last_idx] += self.width() - used_total;
235 }
236 }
237
238 widths
239 }
240
241 pub fn make_table<'a, T: SSS>(
244 &mut self,
245 worker: &'a mut Worker<T>,
246 selector: &mut Selector<T, impl Selection>,
247 matcher: &mut nucleo::Matcher,
248 click: &mut Click,
249 ) -> Table<'a> {
250 let offset = self.bottom as u32;
251 let end = (self.bottom + self.height) as u32;
252 let hz = !self.config.stacked_columns;
253
254 let width_limits = if hz {
255 self.max_widths()
256 } else {
257 vec![
258 if self.config.wrap {
259 self.width
260 } else {
261 u16::MAX
262 };
263 worker.columns.len()
264 ]
265 };
266
267 let (mut results, mut widths, status) =
268 worker.results(offset, end, &width_limits, self.match_style(), matcher);
269
270 let match_count = status.matched_count;
271 self.status = status;
272
273 if match_count < (self.bottom + self.cursor) as u32 && !self.cursor_disabled {
274 self.cursor_jump(match_count);
275 } else {
276 self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
277 }
278
279 widths[0] += self.indentation() as u16;
280
281 let mut rows = vec![];
282 let mut total_height = 0;
283
284 if results.is_empty() {
285 return Table::new(rows, widths);
286 }
287
288 let height_of = |t: &(Vec<ratatui::text::Text<'a>>, _)| {
289 self._hr()
290 + if hz {
291 t.0.iter()
292 .map(|t| t.height() as u16)
293 .max()
294 .unwrap_or_default()
295 } else {
296 t.0.iter().map(|t| t.height() as u16).sum::<u16>()
297 }
298 };
299
300 let h_at_cursor = height_of(&results[self.cursor as usize]);
302 let h_after_cursor = results[self.cursor as usize + 1..]
303 .iter()
304 .map(height_of)
305 .sum();
306 let h_to_cursor = results[0..self.cursor as usize]
307 .iter()
308 .map(height_of)
309 .sum::<u16>();
310 let cursor_end_should_lt = self.height - self.scroll_padding().min(h_after_cursor);
311 let mut start_index = 0; if h_at_cursor >= cursor_end_should_lt {
320 start_index = self.cursor;
321 self.bottom += self.cursor;
322 self.cursor = 0;
323 self.cursor_above = 0;
324 } else
325 if let h_to_cursor_end = h_to_cursor + h_at_cursor
327 && h_to_cursor_end > cursor_end_should_lt
328 {
329 let mut trunc_height = h_to_cursor_end - cursor_end_should_lt;
330 for r in results[start_index as usize..self.cursor as usize].iter_mut() {
333 start_index += 1;
334 let h = height_of(r);
335 let (row, item) = r;
336
337 if trunc_height < h {
338 let mut remaining_height = h - trunc_height;
339 let prefix = if selector.contains(item) {
340 self.config.multi_prefix.clone().to_string()
341 } else {
342 self.default_prefix(0)
343 };
344
345 total_height += remaining_height;
346
347 if hz {
348 if h - self._hr() < remaining_height {
349 for (_, t) in
350 row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0)
351 {
352 clip_text_lines(t, h - remaining_height, !self.reverse());
353 }
354 }
355
356 prefix_text(&mut row[0], prefix);
357
358 let last_visible = widths
359 .iter()
360 .enumerate()
361 .rev()
362 .find_map(|(i, w)| (*w != 0).then_some(i));
363
364 let mut row_texts: Vec<_> = row
365 .iter()
366 .take(last_visible.map(|x| x + 1).unwrap_or(0))
367 .cloned()
368 .collect();
369
370 if self.config.right_align_last && row_texts.len() > 1 {
371 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
372 }
373
374 let row = Row::new(row_texts).height(remaining_height);
375 rows.push(row);
376 } else {
377 let mut push = vec![];
378
379 for col in row.into_iter().rev() {
380 let mut height = col.height() as u16;
381 if remaining_height == 0 {
382 break;
383 } else if remaining_height < height {
384 clip_text_lines(col, remaining_height, !self.reverse());
385 height = remaining_height;
386 }
387 remaining_height -= height;
388 prefix_text(col, prefix.clone());
389 push.push(Row::new(vec![col.clone()]).height(height));
390 }
391 rows.extend(push);
392 }
393
394 self.bottom += start_index - 1;
395 self.cursor -= start_index - 1;
396 self.bottom_clip = Some(remaining_height);
397 break;
398 } else if trunc_height == h {
399 self.bottom += start_index;
400 self.cursor -= start_index;
401 self.bottom_clip = None;
402 break;
403 }
404
405 trunc_height -= h;
406 }
407 } else if let Some(mut remaining_height) = self.bottom_clip {
408 start_index += 1;
409 let h = height_of(&results[0]);
411 let (row, item) = &mut results[0];
412 let prefix = if selector.contains(item) {
413 self.config.multi_prefix.clone().to_string()
414 } else {
415 self.default_prefix(0)
416 };
417
418 total_height += remaining_height;
419
420 if hz {
421 if self._hr() + remaining_height != h {
422 for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
423 clip_text_lines(t, remaining_height, !self.reverse());
424 }
425 }
426
427 prefix_text(&mut row[0], prefix);
428
429 let last_visible = widths
430 .iter()
431 .enumerate()
432 .rev()
433 .find_map(|(i, w)| (*w != 0).then_some(i));
434
435 let mut row_texts: Vec<_> = row
436 .iter()
437 .take(last_visible.map(|x| x + 1).unwrap_or(0))
438 .cloned()
439 .collect();
440
441 if self.config.right_align_last && row_texts.len() > 1 {
442 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
443 }
444
445 let row = Row::new(row_texts).height(remaining_height);
446 rows.push(row);
447 } else {
448 let mut push = vec![];
449
450 for col in row.into_iter().rev() {
451 let mut height = col.height() as u16;
452 if remaining_height == 0 {
453 break;
454 } else if remaining_height < height {
455 clip_text_lines(col, remaining_height, !self.reverse());
456 height = remaining_height;
457 }
458 remaining_height -= height;
459 prefix_text(col, prefix.clone());
460 push.push(Row::new(vec![col.clone()]).height(height));
461 }
462 rows.extend(push);
463 }
464 }
465
466 let mut remaining_height = self.height.saturating_sub(total_height);
470
471 for (mut i, (mut row, item)) in results.drain(start_index as usize..).enumerate() {
472 i += self.bottom_clip.is_some() as usize;
473
474 if let Click::ResultPos(c) = click
476 && self.height - remaining_height > *c
477 {
478 let idx = self.bottom as u32 + i as u32 - 1;
479 log::debug!("Mapped click position to index: {c} -> {idx}",);
480 *click = Click::ResultIdx(idx);
481 }
482 if self.is_current(i) {
483 self.cursor_above = self.height - remaining_height;
484 }
485
486 if let Some(hr) = self.hr()
488 && remaining_height > 0
489 {
490 rows.push(hr);
491 remaining_height -= 1;
492 }
493 if remaining_height == 0 {
494 break;
495 }
496
497 let prefix = if selector.contains(item) {
499 self.config.multi_prefix.clone().to_string()
500 } else {
501 self.default_prefix(i)
502 };
503
504 if hz {
505 let mut height = row
506 .iter()
507 .map(|t| t.height() as u16)
508 .max()
509 .unwrap_or_default();
510
511 if remaining_height < height {
512 height = remaining_height;
513
514 for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
515 clip_text_lines(t, height, self.reverse());
516 }
517 }
518 remaining_height -= height;
519
520 prefix_text(&mut row[0], prefix);
521
522 let last_visible = widths
524 .iter()
525 .enumerate()
526 .rev()
527 .find_map(|(i, w)| (*w != 0).then_some(i));
528
529 let mut row_texts: Vec<_> = row
530 .iter()
531 .take(last_visible.map(|x| x + 1).unwrap_or(0))
532 .cloned()
533 .enumerate()
535 .map(|(x, t)| {
536 if self.is_current(i)
537 && (self.col.is_none()
538 && matches!(
539 self.config.row_connection_style,
540 RowConnectionStyle::Disjoint
541 )
542 || self.col == Some(x))
543 {
544 t.style(self.current_style())
545 } else {
546 t
547 }
548 })
549 .collect();
550
551 if self.config.right_align_last && row_texts.len() > 1 {
552 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
553 }
554
555 let mut row = Row::new(row_texts).height(height);
557
558 if self.is_current(i)
559 && self.col.is_none()
560 && !matches!(
561 self.config.row_connection_style,
562 RowConnectionStyle::Disjoint
563 )
564 {
565 row = row.style(self.current_style())
566 }
567
568 rows.push(row);
569 } else {
570 let mut push = vec![];
571
572 for (x, mut col) in row.into_iter().enumerate() {
573 let mut height = col.height() as u16;
574
575 if remaining_height == 0 {
576 break;
577 } else if remaining_height < height {
578 height = remaining_height;
579 clip_text_lines(&mut col, remaining_height, !self.reverse());
580 }
581 remaining_height -= height;
582
583 prefix_text(&mut col, prefix.clone());
584
585 let mut row = Row::new(vec![col.clone()]).height(height);
587
588 if self.is_current(i) && (self.col.is_none() || self.col == Some(x)) {
589 row = row.style(self.current_style())
590 }
591
592 push.push(row);
593 }
594 rows.extend(push);
595 }
596 }
597
598 if self.reverse() {
599 rows.reverse();
600 if remaining_height > 0 {
601 rows.insert(0, Row::new(vec![vec![]]).height(remaining_height));
602 }
603 }
604
605 if hz {
608 self.widths = {
609 let pos = widths.iter().rposition(|&x| x != 0).map_or(0, |p| p + 1);
610 let mut widths = widths[..pos].to_vec();
611 if pos > 2 && self.config.right_align_last {
612 let used = widths.iter().take(widths.len() - 1).sum();
613 widths[pos - 1] = self.width().saturating_sub(used);
614 }
615 widths
616 };
617 }
618
619 let mut table = Table::new(
621 rows,
622 if hz {
623 self.widths.clone()
624 } else {
625 vec![self.width]
626 },
627 )
628 .column_spacing(self.config.column_spacing.0)
629 .style(self.config.fg)
630 .add_modifier(self.config.modifier);
631
632 table = table.block(self.config.border.as_static_block());
633 table
634 }
635}
636
637impl ResultsUI {
638 pub fn make_status(&self) -> Paragraph<'_> {
639 let substituted = substitute_escaped(
640 &self.status_config.template,
641 &[
642 ('r', &(self.index().to_string())),
643 ('m', &(self.status.matched_count.to_string())),
644 ('t', &(self.status.item_count.to_string())),
645 ],
646 )
647 .pad(
648 self.status_config.match_indent as usize * self.indentation(),
649 0,
650 );
651 Paragraph::new(substituted)
652 .style(self.status_config.fg)
653 .add_modifier(self.status_config.modifier)
654 }
655}
656
657impl ResultsUI {
659 fn default_prefix(&self, i: usize) -> String {
660 let substituted = substitute_escaped(
661 &self.config.default_prefix,
662 &[
663 ('d', &(i + 1).to_string()), ('r', &(i + 1 + self.bottom as usize).to_string()), ],
666 );
667
668 fit_width(&substituted, self.indentation())
669 }
670
671 fn current_style(&self) -> Style {
672 Style::from(self.config.current_fg)
673 .bg(self.config.current_bg)
674 .add_modifier(self.config.current_modifier)
675 }
676
677 fn is_current(&self, i: usize) -> bool {
678 !self.cursor_disabled && self.cursor == i as u16
679 }
680
681 pub fn match_style(&self) -> Style {
682 Style::default()
683 .fg(self.config.match_fg)
684 .add_modifier(self.config.match_modifier)
685 }
686
687 pub fn hr(&self) -> Option<Row<'static>> {
688 let sep = self.config.horizontal_separator;
689
690 if matches!(sep, HorizontalSeparator::None) {
691 return None;
692 }
693
694 if !self.config.stacked_columns && self.widths.len() > 1 {
696 return Some(Row::new(vec![vec![]]));
697 }
698
699 let unit = sep.as_str();
700 let line = unit.repeat(self.width as usize);
701
702 Some(Row::new(vec![line]))
703 }
704
705 pub fn _hr(&self) -> u16 {
706 !matches!(self.config.horizontal_separator, HorizontalSeparator::None) as u16
707 }
708}