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::{clip_text_lines, expand_indents, prefix_text},
20 },
21};
22
23#[derive(Debug)]
24pub struct ResultsUI {
25 cursor: u16,
26 bottom: u32,
27 col: Option<usize>,
28
29 height: u16,
31 width: u16,
33 widths: Vec<u16>,
36
37 pub hidden_columns: Vec<bool>,
38
39 pub status: Status,
40 status_template: Line<'static>,
41 pub status_config: StatusConfig,
42
43 pub config: ResultsConfig,
44
45 bottom_clip: Option<u16>,
46 cursor_above: u16,
47
48 pub cursor_disabled: bool,
49}
50
51impl ResultsUI {
52 pub fn new(config: ResultsConfig, status_config: StatusConfig) -> Self {
53 Self {
54 cursor: 0,
55 bottom: 0,
56 col: None,
57
58 widths: Vec::new(),
59 height: 0, width: 0,
61 hidden_columns: Default::default(),
62
63 status: Default::default(),
64 status_template: Span::from(status_config.template.clone())
65 .style(status_config.fg)
66 .add_modifier(status_config.modifier)
67 .into(),
68 status_config,
69 config,
70
71 cursor_disabled: false,
72 bottom_clip: None,
73 cursor_above: 0,
74 }
75 }
76
77 pub fn hidden_columns(&mut self, hidden_columns: Vec<bool>) {
78 self.hidden_columns = hidden_columns;
79 }
80
81 pub fn update_dimensions(&mut self, area: &Rect) {
83 let [bw, bh] = [self.config.border.height(), self.config.border.width()];
84 self.width = area.width.saturating_sub(bw);
85 self.height = area.height.saturating_sub(bh);
86 log::debug!("Updated results dimensions: {}x{}", self.width, self.height);
87 }
88
89 pub fn table_width(&self) -> u16 {
90 self.config.column_spacing.0 * self.widths().len().saturating_sub(1) as u16
91 + self.widths.iter().sum::<u16>()
92 + self.config.border.width()
93 }
94
95 pub fn reverse(&self) -> bool {
97 self.config.reverse == Some(true)
98 }
99 pub fn is_wrap(&self) -> bool {
100 self.config.wrap
101 }
102 pub fn wrap(&mut self, wrap: bool) {
103 self.config.wrap = wrap;
104 }
105
106 pub fn toggle_col(&mut self, col_idx: usize) -> bool {
109 if self.col == Some(col_idx) {
110 self.col = None
111 } else {
112 self.col = Some(col_idx);
113 }
114 self.col.is_some()
115 }
116 pub fn cycle_col(&mut self) {
117 self.col = match self.col {
118 None => self.widths.is_empty().then_some(0),
119 Some(c) => {
120 let next = c + 1;
121 if next < self.widths.len() {
122 Some(next)
123 } else {
124 None
125 }
126 }
127 };
128 }
129
130 fn scroll_padding(&self) -> u16 {
132 self.config.scroll_padding.min(self.height / 2)
133 }
134 pub fn end(&self) -> u32 {
135 self.status.matched_count.saturating_sub(1)
136 }
137
138 pub fn index(&self) -> u32 {
142 if self.cursor_disabled {
143 u32::MAX
144 } else {
145 self.cursor as u32 + self.bottom
146 }
147 }
148 pub fn cursor_prev(&mut self) {
156 log::trace!("cursor_prev: {self:?}");
157 if self.cursor_above <= self.scroll_padding() && self.bottom > 0 {
158 self.bottom -= 1;
159 self.bottom_clip = None;
160 } else if self.cursor > 0 {
161 self.cursor -= 1;
162 } else if self.config.scroll_wrap {
163 self.cursor_jump(self.end());
164 }
165 }
166 pub fn cursor_next(&mut self) {
167 if self.cursor_disabled {
168 self.cursor_disabled = false
169 }
170
171 if self.cursor + 1 + self.scroll_padding() >= self.height
178 && self.bottom + (self.height as u32) < self.status.matched_count
179 {
180 self.bottom += 1; } else if self.index() < self.end() {
182 self.cursor += 1;
183 } else if self.config.scroll_wrap {
184 self.cursor_jump(0)
185 }
186 }
187
188 pub fn cursor_jump(&mut self, index: u32) {
189 self.cursor_disabled = false;
190 self.bottom_clip = None;
191
192 let end = self.end();
193 let index = index.min(end);
194
195 if index < self.bottom as u32 || index >= self.bottom + self.height as u32 {
196 self.bottom = (end + 1)
197 .saturating_sub(self.height as u32) .min(index);
199 }
200 self.cursor = (index - self.bottom) as u16;
201 log::debug!("cursor jumped to {}: {index}, end: {end}", self.cursor);
202 }
203
204 pub fn indentation(&self) -> usize {
206 self.config.multi_prefix.width()
207 }
208 pub fn col(&self) -> Option<usize> {
209 self.col
210 }
211
212 pub fn widths(&self) -> &Vec<u16> {
215 &self.widths
216 }
217 pub fn width(&self) -> u16 {
219 self.width.saturating_sub(self.indentation() as u16)
220 }
221
222 pub fn max_widths(&self) -> Vec<u16> {
225 let mut scale_total = 0;
226
227 let mut widths = vec![u16::MAX; self.widths.len().max(self.hidden_columns.len())];
228 let mut total = 0; for i in 0..widths.len() {
230 if i < self.hidden_columns.len() && self.hidden_columns[i] || widths[i] == 0 {
231 widths[i] = 0;
232 } else if let Some(&w) = self.widths.get(i) {
233 total += w;
234 if w >= self.config.min_wrap_width {
235 scale_total += w;
236 widths[i] = w;
237 }
238 }
239 }
240
241 if !self.config.wrap || scale_total == 0 {
242 for x in &mut widths {
243 if *x != 0 {
244 *x = u16::MAX
245 }
246 }
247 return widths;
248 }
249
250 let mut last_scalable = None;
251 let available = self.width().saturating_sub(total - scale_total); let mut used_total = 0;
254 for (i, x) in widths.iter_mut().enumerate() {
255 if *x == 0 {
256 continue;
257 }
258 if *x == u16::MAX
259 && let Some(w) = self.widths.get(i)
260 {
261 used_total += w;
262 continue;
263 }
264 let new_w = *x * available / scale_total;
265 *x = new_w.max(self.config.min_wrap_width);
266 used_total += *x;
267 last_scalable = Some(x);
268 }
269
270 if used_total < self.width()
272 && let Some(last) = last_scalable
273 {
274 *last += self.width() - used_total;
275 }
276
277 widths
278 }
279
280 pub fn make_table<'a, T: SSS>(
283 &mut self,
284 worker: &'a mut Worker<T>,
285 selector: &mut Selector<T, impl Selection>,
286 matcher: &mut nucleo::Matcher,
287 click: &mut Click,
288 ) -> Table<'a> {
289 let offset = self.bottom as u32;
290 let end = self.bottom + self.height as u32;
291 let hz = !self.config.stacked_columns;
292
293 let width_limits = if hz {
294 self.max_widths()
295 } else {
296 let default = if self.config.wrap {
297 self.width
298 } else {
299 u16::MAX
300 };
301
302 (0..worker.columns.len())
303 .map(|i| {
304 if self.hidden_columns.get(i).copied().unwrap_or(false) {
305 0
306 } else {
307 default
308 }
309 })
310 .collect()
311 };
312
313 let (mut results, mut widths, status) =
314 worker.results(offset, end, &width_limits, self.match_style(), matcher);
315
316 let match_count = status.matched_count;
319 self.status = status;
320
321 if match_count < self.bottom + self.cursor as u32 && !self.cursor_disabled {
322 self.cursor_jump(match_count);
323 } else {
324 self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
325 }
326
327 widths[0] += self.indentation() as u16;
328
329 let mut rows = vec![];
330 let mut total_height = 0;
331
332 if results.is_empty() {
333 return Table::new(rows, widths);
334 }
335
336 let height_of = |t: &(Vec<ratatui::text::Text<'a>>, _)| {
337 self._hr()
338 + if hz {
339 t.0.iter()
340 .map(|t| t.height() as u16)
341 .max()
342 .unwrap_or_default()
343 } else {
344 t.0.iter().map(|t| t.height() as u16).sum::<u16>()
345 }
346 };
347
348 let h_at_cursor = height_of(&results[self.cursor as usize]);
350 let h_after_cursor = results[self.cursor as usize + 1..]
351 .iter()
352 .map(height_of)
353 .sum();
354 let h_to_cursor = results[0..self.cursor as usize]
355 .iter()
356 .map(height_of)
357 .sum::<u16>();
358 let cursor_end_should_lt = self.height - self.scroll_padding().min(h_after_cursor);
359 let mut start_index = 0; if h_at_cursor >= cursor_end_should_lt {
368 start_index = self.cursor;
369 self.bottom += self.cursor as u32;
370 self.cursor = 0;
371 self.cursor_above = 0;
372 self.bottom_clip = None;
373 } else
374 if let h_to_cursor_end = h_to_cursor + h_at_cursor
376 && h_to_cursor_end > cursor_end_should_lt
377 {
378 let mut trunc_height = h_to_cursor_end - cursor_end_should_lt;
379 for r in results[start_index as usize..self.cursor as usize].iter_mut() {
382 let h = height_of(r);
383 let (row, item) = r;
384 start_index += 1; if trunc_height < h {
387 let mut remaining_height = h - trunc_height;
388 let prefix = if selector.contains(item) {
389 self.config.multi_prefix.clone().to_string()
390 } else {
391 self.default_prefix(0)
392 };
393
394 total_height += remaining_height;
395
396 if hz {
398 if h - self._hr() < remaining_height {
399 for (_, t) in
400 row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0)
401 {
402 clip_text_lines(t, remaining_height, !self.reverse());
403 }
404 }
405
406 prefix_text(&mut row[0], prefix);
407
408 let last_visible = widths
409 .iter()
410 .enumerate()
411 .rev()
412 .find_map(|(i, w)| (*w != 0).then_some(i));
413
414 let mut row_texts: Vec<_> = row
415 .iter()
416 .take(last_visible.map(|x| x + 1).unwrap_or(0))
417 .cloned()
418 .collect();
419
420 if self.config.right_align_last && row_texts.len() > 1 {
421 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
422 }
423
424 let row = Row::new(row_texts).height(remaining_height);
425 rows.push(row);
426 } else {
427 let mut push = vec![];
428
429 for col in row.into_iter().rev() {
430 let mut height = col.height() as u16;
431 if remaining_height == 0 {
432 break;
433 } else if remaining_height < height {
434 clip_text_lines(col, remaining_height, !self.reverse());
435 height = remaining_height;
436 }
437 remaining_height -= height;
438 prefix_text(col, prefix.clone());
439 push.push(Row::new(vec![col.clone()]).height(height));
440 }
441 rows.extend(push.into_iter().rev());
442 }
443
444 self.bottom += start_index as u32 - 1;
445 self.cursor -= start_index - 1;
446 self.bottom_clip = Some(remaining_height);
447 break;
448 } else if trunc_height == h {
449 self.bottom += start_index as u32;
450 self.cursor -= start_index;
451 self.bottom_clip = None;
452 break;
453 }
454
455 trunc_height -= h;
456 }
457 } else if let Some(mut remaining_height) = self.bottom_clip {
458 start_index += 1;
459 let h = height_of(&results[0]);
461 let (row, item) = &mut results[0];
462 let prefix = if selector.contains(item) {
463 self.config.multi_prefix.clone().to_string()
464 } else {
465 self.default_prefix(0)
466 };
467
468 total_height += remaining_height;
469
470 if hz {
471 if self._hr() + remaining_height != h {
472 for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
473 clip_text_lines(t, remaining_height, !self.reverse());
474 }
475 }
476
477 prefix_text(&mut row[0], prefix);
478
479 let last_visible = widths
480 .iter()
481 .enumerate()
482 .rev()
483 .find_map(|(i, w)| (*w != 0).then_some(i));
484
485 let mut row_texts: Vec<_> = row
486 .iter()
487 .take(last_visible.map(|x| x + 1).unwrap_or(0))
488 .cloned()
489 .collect();
490
491 if self.config.right_align_last && row_texts.len() > 1 {
492 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
493 }
494
495 let row = Row::new(row_texts).height(remaining_height);
496 rows.push(row);
497 } else {
498 let mut push = vec![];
499
500 for col in row.into_iter().rev() {
501 let mut height = col.height() as u16;
502 if remaining_height == 0 {
503 break;
504 } else if remaining_height < height {
505 clip_text_lines(col, remaining_height, !self.reverse());
506 height = remaining_height;
507 }
508 remaining_height -= height;
509 prefix_text(col, prefix.clone());
510 push.push(Row::new(vec![col.clone()]).height(height));
511 }
512 rows.extend(push.into_iter().rev());
513 }
514 }
515
516 let mut remaining_height = self.height.saturating_sub(total_height);
520
521 for (mut i, (mut row, item)) in results.drain(start_index as usize..).enumerate() {
522 i += self.bottom_clip.is_some() as usize;
523
524 if let Click::ResultPos(c) = click
526 && self.height - remaining_height > *c
527 {
528 let idx = self.bottom as u32 + i as u32 - 1;
529 log::debug!("Mapped click position to index: {c} -> {idx}",);
530 *click = Click::ResultIdx(idx);
531 }
532 if self.is_current(i) {
533 self.cursor_above = self.height - remaining_height;
534 }
535
536 if let Some(hr) = self.hr()
538 && remaining_height > 0
539 {
540 rows.push(hr);
541 remaining_height -= 1;
542 }
543 if remaining_height == 0 {
544 break;
545 }
546
547 let prefix = if selector.contains(item) {
549 self.config.multi_prefix.clone().to_string()
550 } else {
551 self.default_prefix(i)
552 };
553
554 if hz {
555 let mut height = row
556 .iter()
557 .map(|t| t.height() as u16)
558 .max()
559 .unwrap_or_default();
560
561 if remaining_height < height {
562 height = remaining_height;
563
564 for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
565 clip_text_lines(t, height, self.reverse());
566 }
567 }
568 remaining_height -= height;
569
570 prefix_text(&mut row[0], prefix);
571
572 let last_visible = widths
574 .iter()
575 .enumerate()
576 .rev()
577 .find_map(|(i, w)| (*w != 0).then_some(i));
578
579 let mut row_texts: Vec<_> = row
580 .iter()
581 .take(last_visible.map(|x| x + 1).unwrap_or(0))
582 .cloned()
583 .enumerate()
585 .map(|(x, t)| {
586 if self.is_current(i)
587 && (self.col.is_none()
588 && matches!(
589 self.config.row_connection_style,
590 RowConnectionStyle::Disjoint
591 )
592 || self.col == Some(x))
593 {
594 t.style(self.current_style())
595 } else {
596 t
597 }
598 })
599 .collect();
600
601 if self.config.right_align_last && row_texts.len() > 1 {
602 row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
603 }
604
605 let mut row = Row::new(row_texts).height(height);
607
608 if self.is_current(i)
609 && self.col.is_none()
610 && !matches!(
611 self.config.row_connection_style,
612 RowConnectionStyle::Disjoint
613 )
614 {
615 row = row.style(self.current_style())
616 }
617
618 rows.push(row);
619 } else {
620 let mut push = vec![];
621
622 for (x, mut col) in row.into_iter().enumerate() {
623 let mut height = col.height() as u16;
624
625 if remaining_height == 0 {
626 break;
627 } else if remaining_height < height {
628 height = remaining_height;
629 clip_text_lines(&mut col, remaining_height, self.reverse());
630 }
631 remaining_height -= height;
632
633 prefix_text(&mut col, prefix.clone());
634
635 let mut row = Row::new(vec![col]).height(height);
637
638 if self.is_current(i) && (self.col.is_none() || self.col == Some(x)) {
639 row = row.style(self.current_style())
640 }
641
642 push.push(row);
643 }
644 rows.extend(push);
645 }
646 }
647
648 if self.reverse() {
649 rows.reverse();
650 if remaining_height > 0 {
651 rows.insert(0, Row::new(vec![vec![]]).height(remaining_height));
652 }
653 }
654
655 if hz {
658 self.widths = {
659 let pos = widths.iter().rposition(|&x| x != 0).map_or(0, |p| p + 1);
660 let mut widths = widths[..pos].to_vec();
661 if pos > 2 && self.config.right_align_last {
662 let used = widths.iter().take(widths.len() - 1).sum();
663 widths[pos - 1] = self.width().saturating_sub(used);
664 }
665 widths
666 };
667 }
668
669 let mut table = Table::new(
671 rows,
672 if hz {
673 self.widths.clone()
674 } else {
675 vec![self.width]
676 },
677 )
678 .column_spacing(self.config.column_spacing.0)
679 .style(self.config.fg)
680 .add_modifier(self.config.modifier);
681
682 table = table.block(self.config.border.as_static_block());
683 table
684 }
685}
686
687impl ResultsUI {
688 pub fn make_status(&self, full_width: u16) -> Paragraph<'_> {
689 let status_config = &self.status_config;
690 let replacements = [
691 ('r', self.index().to_string()),
692 ('m', self.status.matched_count.to_string()),
693 ('t', self.status.item_count.to_string()),
694 ];
695
696 let mut new_spans = Vec::new();
698
699 if status_config.match_indent {
700 new_spans.push(Span::raw(" ".repeat(self.indentation())));
701 }
702
703 for span in &self.status_template {
704 let subbed = substitute_escaped(&span.content, &replacements);
705 new_spans.push(Span::styled(subbed, span.style));
706 }
707
708 let substituted_line = Line::from(new_spans);
709
710 let effective_width = match self.status_config.row_connection_style {
712 RowConnectionStyle::Full => full_width,
713 _ => self.width,
714 } as usize;
715 let expanded = expand_indents(substituted_line, r"\s", effective_width)
716 .style(status_config.fg)
717 .add_modifier(status_config.modifier);
718
719 Paragraph::new(expanded)
720 }
721
722 pub fn set_status_line(&mut self, template: Option<Line<'static>>) {
723 let status_config = &self.status_config;
724
725 self.status_template = template
726 .unwrap_or(status_config.template.clone().into())
727 .style(status_config.fg)
728 .add_modifier(status_config.modifier)
729 .into()
730 }
731}
732
733impl ResultsUI {
735 fn default_prefix(&self, i: usize) -> String {
736 let substituted = substitute_escaped(
737 &self.config.default_prefix,
738 &[
739 ('d', &(i + 1).to_string()), ('r', &(i + 1 + self.bottom as usize).to_string()), ],
742 );
743
744 fit_width(&substituted, self.indentation())
745 }
746
747 fn current_style(&self) -> Style {
748 Style::from(self.config.current_fg)
749 .bg(self.config.current_bg)
750 .add_modifier(self.config.current_modifier)
751 }
752
753 fn is_current(&self, i: usize) -> bool {
754 !self.cursor_disabled && self.cursor == i as u16
755 }
756
757 pub fn match_style(&self) -> Style {
758 Style::default()
759 .fg(self.config.match_fg)
760 .add_modifier(self.config.match_modifier)
761 }
762
763 fn hr(&self) -> Option<Row<'static>> {
764 let sep = self.config.horizontal_separator;
765
766 if matches!(sep, HorizontalSeparator::None) {
767 return None;
768 }
769
770 if !self.config.stacked_columns && self.widths.len() > 1 {
772 return Some(Row::new(vec![vec![]]));
773 }
774
775 let unit = sep.as_str();
776 let line = unit.repeat(self.width as usize);
777
778 Some(Row::new(vec![line]))
779 }
780
781 fn _hr(&self) -> u16 {
782 !matches!(self.config.horizontal_separator, HorizontalSeparator::None) as u16
783 }
784}
785
786pub struct StatusUI {}
787
788impl StatusUI {
789 pub fn parse_template_to_status_line(s: &str) -> Line<'static> {
790 let parts = match split_on_nesting(&s, ['{', '}']) {
791 Ok(x) => x,
792 Err(n) => {
793 if n > 0 {
794 log::error!("Encountered {} unclosed parentheses", n)
795 } else {
796 log::error!("Extra closing parenthesis at index {}", -n)
797 }
798 return Line::from(s.to_string());
799 }
800 };
801
802 let mut spans = Vec::new();
803 let mut in_nested = !s.starts_with('{');
804 for part in parts {
805 in_nested = !in_nested;
806 let content = part.as_str();
807
808 if in_nested {
809 let inner = &content[1..content.len() - 1];
810
811 if let Some((color_name, text)) = inner.split_once(':') {
812 if let Ok(color) = Color::from_str(color_name) {
813 spans.push(Span::styled(text.to_string(), Style::default().fg(color)));
814 continue;
815 }
816 }
817 }
818
819 spans.push(Span::raw(content.to_string()));
820 }
821
822 Line::from(spans)
823 }
824}