1use std::str::FromStr;
2
3use cli_boilerplate_automation::bring::split::split_on_nesting;
4use ratatui::{
5 layout::{Alignment, Rect},
6 style::{Color, Style, Stylize},
7 text::{Line, Span},
8 widgets::{Paragraph, Row, Table},
9};
10use unicode_width::UnicodeWidthStr;
11
12use crate::{
13 SSS, Selection, Selector,
14 config::{HorizontalSeparator, ResultsConfig, RowConnectionStyle, StatusConfig},
15 nucleo::{Status, Worker},
16 render::Click,
17 utils::{
18 string::{fit_width, substitute_escaped},
19 text::{apply_to_lines, clip_text_lines, expand_indents, hscroll_line, prefix_text},
20 },
21};
22
23#[derive(Debug)]
24pub struct ResultsUI {
25 cursor: u16,
26 bottom: u32,
27 col: Option<usize>,
28 pub scroll: [u16; 2],
30
31 height: u16,
33 width: u16,
35 widths: Vec<u16>,
38
39 pub hidden_columns: Vec<bool>,
40
41 pub status: Status,
42 status_template: Line<'static>,
43 pub status_config: StatusConfig,
44
45 pub config: ResultsConfig,
46
47 bottom_clip: Option<u16>,
48 cursor_above: u16,
49
50 pub cursor_disabled: bool,
51}
52
53impl ResultsUI {
54 pub fn new(config: ResultsConfig, status_config: StatusConfig) -> Self {
55 Self {
56 cursor: 0,
57 bottom: 0,
58 col: None,
59 scroll: [0, 0],
60
61 widths: Vec::new(),
62 height: 0, width: 0,
64 hidden_columns: Default::default(),
65
66 status: Default::default(),
67 status_template: Span::from(status_config.template.clone())
68 .style(status_config.fg)
69 .add_modifier(status_config.modifier)
70 .into(),
71 status_config,
72 config,
73
74 cursor_disabled: false,
75 bottom_clip: None,
76 cursor_above: 0,
77 }
78 }
79
80 pub fn hidden_columns(&mut self, hidden_columns: Vec<bool>) {
81 self.hidden_columns = hidden_columns;
82 }
83
84 pub fn update_dimensions(&mut self, area: &Rect) {
86 let [bw, bh] = [self.config.border.height(), self.config.border.width()];
87 self.width = area.width.saturating_sub(bw);
88 self.height = area.height.saturating_sub(bh);
89 log::debug!("Updated results dimensions: {}x{}", self.width, self.height);
90 }
91
92 pub fn table_width(&self) -> u16 {
93 self.config.column_spacing.0 * self.widths().len().saturating_sub(1) as u16
94 + self.widths.iter().sum::<u16>()
95 + self.config.border.width()
96 }
97
98 pub fn height(&self) -> u16 {
99 self.height
100 }
101
102 pub fn reverse(&self) -> bool {
104 self.config.reverse == Some(true)
105 }
106 pub fn is_wrap(&self) -> bool {
107 self.config.wrap
108 }
109 pub fn wrap(&mut self, wrap: bool) {
110 self.config.wrap = wrap;
111 }
112
113 pub fn toggle_col(&mut self, col_idx: usize) -> bool {
116 self.hscroll(0);
117
118 if self.col == Some(col_idx) {
119 self.col = None
120 } else {
121 self.col = Some(col_idx);
122 }
123 self.col.is_some()
124 }
125 pub fn cycle_col(&mut self) {
126 self.hscroll(0);
127
128 self.col = match self.col {
129 None => self.widths.is_empty().then_some(0),
130 Some(c) => {
131 let next = c + 1;
132 if next < self.widths.len() {
133 Some(next)
134 } else {
135 None
136 }
137 }
138 };
139 }
140
141 fn scroll_padding(&self) -> u16 {
143 self.config.scroll_padding.min(self.height / 2)
144 }
145 pub fn end(&self) -> u32 {
146 self.status.matched_count.saturating_sub(1)
147 }
148
149 pub fn index(&self) -> u32 {
153 if self.cursor_disabled {
154 u32::MAX
155 } else {
156 self.cursor as u32 + self.bottom
157 }
158 }
159 pub fn cursor_prev(&mut self) {
167 self.hscroll(0);
168
169 log::trace!("cursor_prev: {self:?}");
170 if self.cursor_above <= self.scroll_padding() && self.bottom > 0 {
171 self.bottom -= 1;
172 self.bottom_clip = None;
173 } else if self.cursor > 0 {
174 self.cursor -= 1;
175 } else if self.config.scroll_wrap {
176 self.cursor_jump(self.end());
177 }
178 }
179 pub fn cursor_next(&mut self) {
180 self.hscroll(0);
181
182 if self.cursor_disabled {
183 self.cursor_disabled = false
184 }
185
186 if self.cursor + 1 + self.scroll_padding() >= self.height
193 && self.bottom + (self.height as u32) < self.status.matched_count
194 {
195 self.bottom += 1; } else if self.index() < self.end() {
197 self.cursor += 1;
198 } else if self.config.scroll_wrap {
199 self.cursor_jump(0)
200 }
201 }
202
203 pub fn cursor_jump(&mut self, index: u32) {
204 self.hscroll(0);
205
206 self.cursor_disabled = false;
207 self.bottom_clip = None;
208
209 let end = self.end();
210 let index = index.min(end);
211
212 if index < self.bottom as u32 || index >= self.bottom + self.height as u32 {
213 self.bottom = (end + 1)
214 .saturating_sub(self.height as u32) .min(index);
216 }
217 self.cursor = (index - self.bottom) as u16;
218 log::debug!("cursor jumped to {}: {index}, end: {end}", self.cursor);
219 }
220
221 pub fn hscroll(&mut self, x: i8) {
222 let value = &mut self.scroll[1];
223 *value = if x.is_negative() {
224 value.saturating_sub(x.unsigned_abs() as u16)
225 } else if x.is_positive() {
226 value.saturating_add(x as u16)
227 } else {
228 0
229 };
230 }
232
233 pub fn indentation(&self) -> usize {
235 self.config.multi_prefix.width()
236 }
237 pub fn col(&self) -> Option<usize> {
238 self.col
239 }
240
241 pub fn widths(&self) -> &Vec<u16> {
244 &self.widths
245 }
246 pub fn width(&self) -> u16 {
248 self.width.saturating_sub(self.indentation() as u16)
249 }
250
251 pub fn max_widths(&self) -> Vec<u16> {
254 let mut scale_total = 0;
255
256 let mut widths = vec![u16::MAX; self.widths.len().max(self.hidden_columns.len())];
257 let mut total = 0; for i in 0..widths.len() {
259 if i < self.hidden_columns.len() && self.hidden_columns[i] || widths[i] == 0 {
260 widths[i] = 0;
261 } else if let Some(&w) = self.widths.get(i) {
262 total += w;
263 if w >= self.config.min_wrap_width {
264 scale_total += w;
265 widths[i] = w;
266 }
267 }
268 }
269
270 if !self.config.wrap || scale_total == 0 {
271 for x in &mut widths {
272 if *x != 0 {
273 *x = u16::MAX
274 }
275 }
276 return widths;
277 }
278
279 let mut last_scalable = None;
280 let available = self.width().saturating_sub(total - scale_total); let mut used_total = 0;
283 for (i, x) in widths.iter_mut().enumerate() {
284 if *x == 0 {
285 continue;
286 }
287 if *x == u16::MAX
288 && let Some(w) = self.widths.get(i)
289 {
290 used_total += w;
291 continue;
292 }
293 let new_w = *x * available / scale_total;
294 *x = new_w.max(self.config.min_wrap_width);
295 used_total += *x;
296 last_scalable = Some(x);
297 }
298
299 if used_total < self.width()
301 && let Some(last) = last_scalable
302 {
303 *last += self.width() - used_total;
304 }
305
306 widths
307 }
308
309 pub fn make_table<'a, T: SSS>(
312 &mut self,
313 worker: &'a mut Worker<T>,
314 selector: &mut Selector<T, impl Selection>,
315 matcher: &mut nucleo::Matcher,
316 click: &mut Click,
317 ) -> Table<'a> {
318 let offset = self.bottom as u32;
319 let end = self.bottom + self.height as u32;
320 let hz = !self.config.stacked_columns;
321
322 let width_limits = if hz {
323 self.max_widths()
324 } else {
325 let default = if self.config.wrap {
326 self.width
327 } else {
328 u16::MAX
329 };
330
331 (0..worker.columns.len())
332 .map(|i| {
333 if self.hidden_columns.get(i).copied().unwrap_or(false) {
334 0
335 } else {
336 default
337 }
338 })
339 .collect()
340 };
341
342 let (mut results, mut widths, status) = worker.results(
343 offset,
344 end,
345 &width_limits,
346 self.match_style(),
347 matcher,
348 self.config.match_start_context,
349 );
350
351 let match_count = status.matched_count;
354 self.status = status;
355
356 if match_count < self.bottom + self.cursor as u32 && !self.cursor_disabled {
357 self.cursor_jump(match_count);
358 } else {
359 self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
360 }
361
362 widths[0] += self.indentation() as u16;
363
364 let mut rows = vec![];
365 let mut total_height = 0;
366
367 if results.is_empty() {
368 return Table::new(rows, widths);
369 }
370
371 let height_of = |t: &(Vec<ratatui::text::Text<'a>>, _)| {
372 self._hr()
373 + if hz {
374 t.0.iter()
375 .map(|t| t.height() as u16)
376 .max()
377 .unwrap_or_default()
378 } else {
379 t.0.iter().map(|t| t.height() as u16).sum::<u16>()
380 }
381 };
382
383 let h_at_cursor = height_of(&results[self.cursor as usize]);
385 let h_after_cursor = results[self.cursor as usize + 1..]
386 .iter()
387 .map(height_of)
388 .sum();
389 let h_to_cursor = results[0..self.cursor as usize]
390 .iter()
391 .map(height_of)
392 .sum::<u16>();
393 let cursor_end_should_lt = self.height - self.scroll_padding().min(h_after_cursor);
394 let mut start_index = 0; if h_at_cursor >= cursor_end_should_lt {
403 start_index = self.cursor;
404 self.bottom += self.cursor as u32;
405 self.cursor = 0;
406 self.cursor_above = 0;
407 self.bottom_clip = None;
408 } else
409 if let h_to_cursor_end = h_to_cursor + h_at_cursor
411 && h_to_cursor_end > cursor_end_should_lt
412 {
413 let mut trunc_height = h_to_cursor_end - cursor_end_should_lt;
414 for r in results[start_index as usize..self.cursor as usize].iter_mut() {
417 let h = height_of(r);
418 let (row, item) = r;
419 start_index += 1; if trunc_height < h {
422 let mut remaining_height = h - trunc_height;
423 let prefix = if selector.contains(item) {
424 self.config.multi_prefix.clone().to_string()
425 } else {
426 self.default_prefix(0)
427 };
428
429 total_height += remaining_height;
430
431 if hz {
433 if h - self._hr() < remaining_height {
434 for (_, t) in
435 row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0)
436 {
437 clip_text_lines(t, remaining_height, !self.reverse());
438 }
439 }
440
441 prefix_text(&mut row[0], prefix);
442
443 let last_visible = widths
444 .iter()
445 .enumerate()
446 .rev()
447 .find_map(|(i, w)| (*w != 0).then_some(i));
448
449 let mut row_texts: Vec<_> = row
450 .iter()
451 .take(last_visible.map(|x| x + 1).unwrap_or(0))
452 .cloned()
453 .collect();
454
455 if self.config.right_align_last && row_texts.len() > 1 {
456 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
457 }
458
459 let row = Row::new(row_texts).height(remaining_height);
460 rows.push(row);
461 } else {
462 let mut push = vec![];
463
464 for col in row.into_iter().rev() {
465 let mut height = col.height() as u16;
466 if remaining_height == 0 {
467 break;
468 } else if remaining_height < height {
469 clip_text_lines(col, remaining_height, !self.reverse());
470 height = remaining_height;
471 }
472 remaining_height -= height;
473 prefix_text(col, prefix.clone());
474 push.push(Row::new(vec![col.clone()]).height(height));
475 }
476 rows.extend(push.into_iter().rev());
477 }
478
479 self.bottom += start_index as u32 - 1;
480 self.cursor -= start_index - 1;
481 self.bottom_clip = Some(remaining_height);
482 break;
483 } else if trunc_height == h {
484 self.bottom += start_index as u32;
485 self.cursor -= start_index;
486 self.bottom_clip = None;
487 break;
488 }
489
490 trunc_height -= h;
491 }
492 } else if let Some(mut remaining_height) = self.bottom_clip {
493 start_index += 1;
494 let h = height_of(&results[0]);
496 let (row, item) = &mut results[0];
497 let prefix = if selector.contains(item) {
498 self.config.multi_prefix.clone().to_string()
499 } else {
500 self.default_prefix(0)
501 };
502
503 total_height += remaining_height;
504
505 if hz {
506 if self._hr() + remaining_height != h {
507 for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
508 clip_text_lines(t, remaining_height, !self.reverse());
509 }
510 }
511
512 prefix_text(&mut row[0], prefix);
513
514 let last_visible = widths
515 .iter()
516 .enumerate()
517 .rev()
518 .find_map(|(i, w)| (*w != 0).then_some(i));
519
520 let mut row_texts: Vec<_> = row
521 .iter()
522 .take(last_visible.map(|x| x + 1).unwrap_or(0))
523 .cloned()
524 .collect();
525
526 if self.config.right_align_last && row_texts.len() > 1 {
527 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
528 }
529
530 let row = Row::new(row_texts).height(remaining_height);
531 rows.push(row);
532 } else {
533 let mut push = vec![];
534
535 for col in row.into_iter().rev() {
536 let mut height = col.height() as u16;
537 if remaining_height == 0 {
538 break;
539 } else if remaining_height < height {
540 clip_text_lines(col, remaining_height, !self.reverse());
541 height = remaining_height;
542 }
543 remaining_height -= height;
544 prefix_text(col, prefix.clone());
545 push.push(Row::new(vec![col.clone()]).height(height));
546 }
547 rows.extend(push.into_iter().rev());
548 }
549 }
550
551 let mut remaining_height = self.height.saturating_sub(total_height);
555
556 for (mut i, (mut row, item)) in results.drain(start_index as usize..).enumerate() {
557 i += self.bottom_clip.is_some() as usize;
558
559 if let Click::ResultPos(c) = click
561 && self.height - remaining_height > *c
562 {
563 let idx = self.bottom as u32 + i as u32 - 1;
564 log::debug!("Mapped click position to index: {c} -> {idx}",);
565 *click = Click::ResultIdx(idx);
566 }
567 if self.is_current(i) {
568 self.cursor_above = self.height - remaining_height;
569 }
570
571 if let Some(hr) = self.hr()
573 && remaining_height > 0
574 {
575 rows.push(hr);
576 remaining_height -= 1;
577 }
578 if remaining_height == 0 {
579 break;
580 }
581
582 let prefix = if selector.contains(item) {
584 self.config.multi_prefix.clone().to_string()
585 } else {
586 self.default_prefix(i)
587 };
588
589 if hz {
590 if self.is_current(i) && self.scroll[0] > 0 {
592 for (x, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
593 if self.col.is_none() || self.col() == Some(x) {
594 let scroll = self.scroll[0] as usize;
595
596 if scroll < t.lines.len() {
597 t.lines = t.lines.split_off(scroll);
598 } else {
599 t.lines.clear();
600 }
601 }
602 }
603 }
604
605 let mut height = row
606 .iter()
607 .map(|t| t.height() as u16)
608 .max()
609 .unwrap_or_default();
610
611 if remaining_height < height {
612 height = remaining_height;
613
614 for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
615 clip_text_lines(t, height, self.reverse());
616 }
617 }
618 remaining_height -= height;
619
620 let last_visible = widths
622 .iter()
623 .enumerate()
624 .rev()
625 .find_map(|(i, w)| (*w != 0).then_some(i));
626
627 let mut row_texts: Vec<_> = row
628 .iter()
629 .take(last_visible.map(|x| x + 1).unwrap_or(0))
630 .cloned()
631 .enumerate()
633 .map(|(x, mut t)| {
634 let mut t = if self.is_current(i)
635 && (self.col.is_none() || self.col == Some(x))
636 {
637 if self.scroll[1] > 0 {
638 apply_to_lines(&mut t, |line| hscroll_line(line, self.scroll[1]));
639 }
640
641 if self.col.is_none()
642 && matches!(
643 self.config.row_connection_style,
644 RowConnectionStyle::Disjoint
645 )
646 {
647 t.style(self.current_style())
648 } else {
649 t
650 }
651 } else {
652 t
653 };
654
655 if x == 0 {
657 prefix_text(&mut t, prefix.clone());
658 };
659 t
660 })
661 .collect();
662
663 if self.config.right_align_last && row_texts.len() > 1 {
664 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
665 }
666
667 let mut row = Row::new(row_texts).height(height);
669
670 if self.is_current(i)
671 && self.col.is_none()
672 && !matches!(
673 self.config.row_connection_style,
674 RowConnectionStyle::Disjoint
675 )
676 {
677 row = row.style(self.current_style())
678 }
679
680 rows.push(row);
681 } else {
682 let mut push = vec![];
683
684 for (x, mut col) in row.into_iter().enumerate() {
685 let mut height = col.height() as u16;
686
687 if remaining_height == 0 {
688 break;
689 } else if remaining_height < height {
690 height = remaining_height;
691 clip_text_lines(&mut col, remaining_height, self.reverse());
692 }
693 remaining_height -= height;
694
695 if self.is_current(i)
696 && self.scroll[1] > 0
697 && (self.col.is_none() || self.col() == Some(x))
698 {
699 apply_to_lines(&mut col, |line| hscroll_line(line, self.scroll[1]));
700 }
701 if self.is_current(i)
702 && self.scroll[0] > 0
703 && (self.col.is_none() || self.col() == Some(x))
704 {
705 let scroll = self.scroll[0] as usize;
706
707 if scroll < col.lines.len() {
708 col.lines = col.lines.split_off(scroll);
709 } else {
710 col.lines.clear();
711 }
712 }
713
714 prefix_text(&mut col, prefix.clone());
715 if self.is_current(i) && (self.col.is_none() || self.col == Some(x)) {
716 col = col.style(self.current_style())
717 }
718
719 let row = Row::new(vec![col]).height(height);
721 push.push(row);
722 }
723 rows.extend(push);
724 }
725 }
726
727 if self.reverse() {
728 rows.reverse();
729 if remaining_height > 0 {
730 rows.insert(0, Row::new(vec![vec![]]).height(remaining_height));
731 }
732 }
733
734 if hz {
737 self.widths = {
738 let pos = widths.iter().rposition(|&x| x != 0).map_or(0, |p| p + 1);
739 let mut widths = widths[..pos].to_vec();
740 if pos > 2 && self.config.right_align_last {
741 let used = widths.iter().take(widths.len() - 1).sum();
742 widths[pos - 1] = self.width().saturating_sub(used);
743 }
744 widths
745 };
746 }
747
748 let mut table = Table::new(
750 rows,
751 if hz {
752 self.widths.clone()
753 } else {
754 vec![self.width]
755 },
756 )
757 .column_spacing(self.config.column_spacing.0)
758 .style(self.config.fg)
759 .add_modifier(self.config.modifier)
760 .bg(self.config.bg);
761
762 table = table.block(self.config.border.as_static_block());
763 table
764 }
765}
766
767impl ResultsUI {
768 pub fn make_status(&self, full_width: u16) -> Paragraph<'_> {
769 let status_config = &self.status_config;
770 let replacements = [
771 ('r', self.index().to_string()),
772 ('m', self.status.matched_count.to_string()),
773 ('t', self.status.item_count.to_string()),
774 ];
775
776 let mut new_spans = Vec::new();
778
779 if status_config.match_indent {
780 new_spans.push(Span::raw(" ".repeat(self.indentation())));
781 }
782
783 for span in &self.status_template {
784 let subbed = substitute_escaped(&span.content, &replacements);
785 new_spans.push(Span::styled(subbed, span.style));
786 }
787
788 let substituted_line = Line::from(new_spans);
789
790 let effective_width = match self.status_config.row_connection_style {
792 RowConnectionStyle::Full => full_width,
793 _ => self.width,
794 } as usize;
795 let expanded = expand_indents(substituted_line, r"\s", effective_width)
796 .style(status_config.fg)
797 .add_modifier(status_config.modifier);
798
799 Paragraph::new(expanded)
800 }
801
802 pub fn set_status_line(&mut self, template: Option<Line<'static>>) {
803 let status_config = &self.status_config;
804
805 self.status_template = template
806 .unwrap_or(status_config.template.clone().into())
807 .style(status_config.fg)
808 .add_modifier(status_config.modifier)
809 .into()
810 }
811}
812
813impl ResultsUI {
815 fn default_prefix(&self, i: usize) -> String {
816 let substituted = substitute_escaped(
817 &self.config.default_prefix,
818 &[
819 ('d', &(i + 1).to_string()), ('r', &(i + 1 + self.bottom as usize).to_string()), ],
822 );
823
824 fit_width(&substituted, self.indentation())
825 }
826
827 fn current_style(&self) -> Style {
828 Style::from(self.config.current_fg)
829 .bg(self.config.current_bg)
830 .add_modifier(self.config.current_modifier)
831 }
832
833 fn is_current(&self, i: usize) -> bool {
834 !self.cursor_disabled && self.cursor == i as u16
835 }
836
837 pub fn match_style(&self) -> Style {
838 Style::default()
839 .fg(self.config.match_fg)
840 .add_modifier(self.config.match_modifier)
841 }
842
843 fn hr(&self) -> Option<Row<'static>> {
844 let sep = self.config.horizontal_separator;
845
846 if matches!(sep, HorizontalSeparator::None) {
847 return None;
848 }
849
850 let unit = sep.as_str();
851 let line = unit.repeat(self.width as usize);
852
853 if !self.config.stacked_columns && self.widths.len() > 1 {
855 Some(Row::new(vec![line; self.widths().len()]))
857 } else {
858 Some(Row::new(vec![line]))
859 }
860 }
861
862 fn _hr(&self) -> u16 {
863 !matches!(self.config.horizontal_separator, HorizontalSeparator::None) as u16
864 }
865}
866
867pub struct StatusUI {}
868
869impl StatusUI {
870 pub fn parse_template_to_status_line(s: &str) -> Line<'static> {
871 let parts = match split_on_nesting(&s, ['{', '}']) {
872 Ok(x) => x,
873 Err(n) => {
874 if n > 0 {
875 log::error!("Encountered {} unclosed parentheses", n)
876 } else {
877 log::error!("Extra closing parenthesis at index {}", -n)
878 }
879 return Line::from(s.to_string());
880 }
881 };
882
883 let mut spans = Vec::new();
884 let mut in_nested = !s.starts_with('{');
885 for part in parts {
886 in_nested = !in_nested;
887 let content = part.as_str();
888
889 if in_nested {
890 let inner = &content[1..content.len() - 1];
891
892 if let Some((color_name, text)) = inner.split_once(':') {
893 if let Ok(color) = Color::from_str(color_name) {
894 spans.push(Span::styled(text.to_string(), Style::default().fg(color)));
895 continue;
896 }
897 }
898 }
899
900 spans.push(Span::raw(content.to_string()));
901 }
902
903 Line::from(spans)
904 }
905}