Skip to main content

matchmaker/ui/
results.rs

1use std::str::FromStr;
2
3use cba::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    /// y, x
29    pub scroll: [u16; 2],
30
31    /// available height
32    height: u16,
33    /// available width
34    width: u16,
35    // column widths.
36    // Note that the first width includes the indentation.
37    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, // uninitialized, so be sure to call update_dimensions
63            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    // as given by ratatui area
85    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    // ------ config -------
103    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    // ----- columns --------
114    // todo: support cooler things like only showing/outputting a specific column/cycling columns
115    pub fn toggle_col(&mut self, col_idx: usize) -> bool {
116        self.reset_current_scroll();
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.reset_current_scroll();
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    // ------- NAVIGATION ---------
142    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    /// Index in worker snapshot of current item.
150    /// Use with worker.get_nth().
151    //  Equivalently, the cursor progress in the match list
152    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(&self) -> Option<u16> {
160    //     if self.cursor_disabled {
161    //         None
162    //     } else {
163    //         Some(self.cursor)
164    //     }
165    // }
166    pub fn cursor_prev(&mut self) {
167        self.reset_current_scroll();
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.reset_current_scroll();
181
182        if self.cursor_disabled {
183            self.cursor_disabled = false
184        }
185
186        // log::trace!(
187        //     "Cursor {} @ index {}. Status: {:?}.",
188        //     self.cursor,
189        //     self.index(),
190        //     self.status
191        // );
192        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; //
196        } 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.reset_current_scroll();
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) // don't exceed the first item of the last self.height items
215                .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 current_scroll(&mut self, x: i8, horizontal: bool) {
222        let value = &mut self.scroll[horizontal as usize];
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        // log::trace!("hscroll:: {value}");
231    }
232
233    pub fn reset_current_scroll(&mut self) {
234        self.scroll = [0, 0]
235    }
236
237    // ------- RENDERING ----------
238    pub fn indentation(&self) -> usize {
239        self.config.multi_prefix.width()
240    }
241    pub fn col(&self) -> Option<usize> {
242        self.col
243    }
244
245    /// Column widths.
246    /// Note that the first width includes the indentation.
247    pub fn widths(&self) -> &Vec<u16> {
248        &self.widths
249    }
250    // results width
251    pub fn width(&self) -> u16 {
252        self.width.saturating_sub(self.indentation() as u16)
253    }
254
255    /// Adapt the stored widths (initialized by [`Worker::results`]) to the fit within the available width (self.width)
256    /// widths <= min_wrap_width don't shrink and aren't wrapped
257    pub fn max_widths(&self) -> Vec<u16> {
258        let mut scale_total = 0;
259
260        let mut widths = vec![u16::MAX; self.widths.len().max(self.hidden_columns.len())];
261
262        let mut total = 0; // total current width
263        for i in 0..widths.len() {
264            if i < self.hidden_columns.len() && self.hidden_columns[i] {
265                widths[i] = 0;
266            } else if let Some(&w) = self.widths.get(i) {
267                total += w;
268                if w >= self.config.min_wrap_width {
269                    scale_total += w;
270                    widths[i] = w;
271                }
272            }
273        }
274
275        if !self.config.wrap || scale_total == 0 {
276            for x in &mut widths {
277                if *x != 0 {
278                    *x = u16::MAX
279                }
280            }
281            return widths;
282        }
283
284        let mut last_scalable = None;
285        let available = self.width().saturating_sub(total - scale_total); //
286
287        let mut used_total = 0;
288        for (i, x) in widths.iter_mut().enumerate() {
289            if *x == 0 {
290                continue;
291            }
292            if *x == u16::MAX
293                && let Some(w) = self.widths.get(i)
294            {
295                used_total += w;
296                continue;
297            }
298            let new_w = *x * available / scale_total;
299            *x = new_w.max(self.config.min_wrap_width);
300            used_total += *x;
301            last_scalable = Some(x);
302        }
303
304        // give remainder to the last scalable column
305        if used_total < self.width()
306            && let Some(last) = last_scalable
307        {
308            *last += self.width() - used_total;
309        }
310
311        widths
312    }
313
314    // this updates the internal status, so be sure to call make_status afterward
315    // some janky wrapping is implemented, dunno whats causing flickering, padding is fixed going down only
316    pub fn make_table<'a, T: SSS>(
317        &mut self,
318        active_column: usize,
319        worker: &'a mut Worker<T>,
320        selector: &mut Selector<T, impl Selection>,
321        matcher: &mut nucleo::Matcher,
322        click: &mut Click,
323    ) -> Table<'a> {
324        let offset = self.bottom as u32;
325        let end = self.bottom + self.height as u32;
326        let hz = !self.config.stacked_columns;
327
328        let width_limits = if hz {
329            self.max_widths()
330        } else {
331            let default = if self.config.wrap {
332                self.width
333            } else {
334                u16::MAX
335            };
336
337            (0..worker.columns.len())
338                .map(|i| {
339                    if self.hidden_columns.get(i).copied().unwrap_or(false) {
340                        0
341                    } else {
342                        default
343                    }
344                })
345                .collect()
346        };
347
348        let (mut results, mut widths, status) = worker.results(
349            offset,
350            end,
351            &width_limits,
352            self.match_style(),
353            matcher,
354            self.config.match_start_context,
355        );
356
357        // log::debug!("widths: {width_limits:?}, {widths:?}");
358
359        let match_count = status.matched_count;
360        self.status = status;
361
362        if match_count < self.bottom + self.cursor as u32 && !self.cursor_disabled {
363            self.cursor_jump(match_count);
364        } else {
365            self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
366        }
367
368        widths[0] += self.indentation() as u16;
369
370        let mut rows = vec![];
371        let mut total_height = 0;
372
373        if results.is_empty() {
374            return Table::new(rows, widths);
375        }
376
377        let height_of = |t: &(Vec<ratatui::text::Text<'a>>, _)| {
378            self._hr()
379                + if hz {
380                    t.0.iter()
381                        .map(|t| t.height() as u16)
382                        .max()
383                        .unwrap_or_default()
384                } else {
385                    t.0.iter().map(|t| t.height() as u16).sum::<u16>()
386                }
387        };
388
389        // log::debug!("results initial: {}, {}, {}, {}, {}", self.bottom, self.cursor, total_height, self.height, results.len());
390        let h_at_cursor = height_of(&results[self.cursor as usize]);
391        let h_after_cursor = results[self.cursor as usize + 1..]
392            .iter()
393            .map(height_of)
394            .sum();
395        let h_to_cursor = results[0..self.cursor as usize]
396            .iter()
397            .map(height_of)
398            .sum::<u16>();
399        let cursor_end_should_lt = self.height - self.scroll_padding().min(h_after_cursor);
400        // let cursor_start_should_gt = self.scroll_padding().min(h_to_cursor);
401
402        // log::debug!(
403        //     "Computed heights: {h_at_cursor}, {h_to_cursor}, {h_after_cursor}, {cursor_end_should_lt}",
404        // );
405        // begin adjustment
406        let mut start_index = 0; // the index in results of the first complete item
407
408        if h_at_cursor >= cursor_end_should_lt {
409            start_index = self.cursor;
410            self.bottom += self.cursor as u32;
411            self.cursor = 0;
412            self.cursor_above = 0;
413            self.bottom_clip = None;
414        } else
415        // increase the bottom index so that cursor_should_above is maintained
416        if let h_to_cursor_end = h_to_cursor + h_at_cursor
417            && h_to_cursor_end > cursor_end_should_lt
418        {
419            let mut trunc_height = h_to_cursor_end - cursor_end_should_lt;
420            // note that there is a funny side effect that scrolling up near the bottom can scroll up a bit, but it seems fine to me
421
422            for r in results[start_index as usize..self.cursor as usize].iter_mut() {
423                let h = height_of(r);
424                let (row, item) = r;
425                start_index += 1; // we always skip at least the first item
426
427                if trunc_height < h {
428                    let mut remaining_height = h - trunc_height;
429                    let prefix = if selector.contains(item) {
430                        self.config.multi_prefix.clone().to_string()
431                    } else {
432                        self.default_prefix(0)
433                    };
434
435                    total_height += remaining_height;
436
437                    // log::debug!("r: {remaining_height}");
438                    if hz {
439                        if h - self._hr() < remaining_height {
440                            for (_, t) in
441                                row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0)
442                            {
443                                clip_text_lines(t, remaining_height, !self.reverse());
444                            }
445                        }
446
447                        prefix_text(&mut row[0], prefix);
448
449                        let last_visible = widths
450                            .iter()
451                            .enumerate()
452                            .rev()
453                            .find_map(|(i, w)| (*w != 0).then_some(i));
454
455                        let mut row_texts: Vec<_> = row
456                            .iter()
457                            .take(last_visible.map(|x| x + 1).unwrap_or(0))
458                            .cloned()
459                            .collect();
460
461                        if self.config.right_align_last && row_texts.len() > 1 {
462                            row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
463                        }
464
465                        let row = Row::new(row_texts).height(remaining_height);
466                        rows.push(row);
467                    } else {
468                        let mut push = vec![];
469
470                        for col in row.into_iter().rev() {
471                            let mut height = col.height() as u16;
472                            if remaining_height == 0 {
473                                break;
474                            } else if remaining_height < height {
475                                clip_text_lines(col, remaining_height, !self.reverse());
476                                height = remaining_height;
477                            }
478                            remaining_height -= height;
479                            prefix_text(col, prefix.clone());
480                            push.push(Row::new(vec![col.clone()]).height(height));
481                        }
482                        rows.extend(push.into_iter().rev());
483                    }
484
485                    self.bottom += start_index as u32 - 1;
486                    self.cursor -= start_index - 1;
487                    self.bottom_clip = Some(remaining_height);
488                    break;
489                } else if trunc_height == h {
490                    self.bottom += start_index as u32;
491                    self.cursor -= start_index;
492                    self.bottom_clip = None;
493                    break;
494                }
495
496                trunc_height -= h;
497            }
498        } else if let Some(mut remaining_height) = self.bottom_clip {
499            start_index += 1;
500            // same as above
501            let h = height_of(&results[0]);
502            let (row, item) = &mut results[0];
503            let prefix = if selector.contains(item) {
504                self.config.multi_prefix.clone().to_string()
505            } else {
506                self.default_prefix(0)
507            };
508
509            total_height += remaining_height;
510
511            if hz {
512                if self._hr() + remaining_height != h {
513                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
514                        clip_text_lines(t, remaining_height, !self.reverse());
515                    }
516                }
517
518                prefix_text(&mut row[0], prefix);
519
520                let last_visible = widths
521                    .iter()
522                    .enumerate()
523                    .rev()
524                    .find_map(|(i, w)| (*w != 0).then_some(i));
525
526                let mut row_texts: Vec<_> = row
527                    .iter()
528                    .take(last_visible.map(|x| x + 1).unwrap_or(0))
529                    .cloned()
530                    .collect();
531
532                if self.config.right_align_last && row_texts.len() > 1 {
533                    row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
534                }
535
536                let row = Row::new(row_texts).height(remaining_height);
537                rows.push(row);
538            } else {
539                let mut push = vec![];
540
541                for col in row.into_iter().rev() {
542                    let mut height = col.height() as u16;
543                    if remaining_height == 0 {
544                        break;
545                    } else if remaining_height < height {
546                        clip_text_lines(col, remaining_height, !self.reverse());
547                        height = remaining_height;
548                    }
549                    remaining_height -= height;
550                    prefix_text(col, prefix.clone());
551                    push.push(Row::new(vec![col.clone()]).height(height));
552                }
553                rows.extend(push.into_iter().rev());
554            }
555        }
556
557        // topside padding is non-flexible, and does its best to stay at 2 full items without obscuring cursor.
558        // One option is we move enforcement from cursor_prev to
559
560        let mut remaining_height = self.height.saturating_sub(total_height);
561
562        for (mut i, (mut row, item)) in results.drain(start_index as usize..).enumerate() {
563            i += self.bottom_clip.is_some() as usize;
564
565            // this is technically one step out of sync but idc
566            if let Click::ResultPos(c) = click
567                && self.height - remaining_height > *c
568            {
569                let idx = self.bottom as u32 + i as u32 - 1;
570                log::debug!("Mapped click position to index: {c} -> {idx}",);
571                *click = Click::ResultIdx(idx);
572            }
573            if self.is_current(i) {
574                self.cursor_above = self.height - remaining_height;
575            }
576
577            // insert hr
578            if let Some(hr) = self.hr()
579                && remaining_height > 0
580            {
581                rows.push(hr);
582                remaining_height -= 1;
583            }
584            if remaining_height == 0 {
585                break;
586            }
587
588            // determine prefix
589            let prefix = if selector.contains(item) {
590                self.config.multi_prefix.clone().to_string()
591            } else {
592                self.default_prefix(i)
593            };
594
595            if hz {
596                // scroll down
597                if self.is_current(i) && self.scroll[0] > 0 {
598                    for (x, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
599                        if self.col.is_none() || self.col() == Some(x) {
600                            let scroll = self.scroll[0] as usize;
601
602                            if scroll < t.lines.len() {
603                                t.lines = t.lines.split_off(scroll);
604                            } else {
605                                t.lines.clear();
606                            }
607                        }
608                    }
609                }
610
611                let mut height = row
612                    .iter()
613                    .map(|t| t.height() as u16)
614                    .max()
615                    .unwrap_or_default();
616
617                if remaining_height < height {
618                    height = remaining_height;
619
620                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
621                        clip_text_lines(t, height, self.reverse());
622                    }
623                }
624                remaining_height -= height;
625
626                // same as above
627                let last_visible = widths
628                    .iter()
629                    .enumerate()
630                    .rev()
631                    .find_map(|(i, w)| (*w != 0).then_some(i));
632
633                let mut row_texts: Vec<_> = row
634                    .iter()
635                    .take(last_visible.map(|x| x + 1).unwrap_or(0))
636                    .cloned()
637                    // highlight
638                    .enumerate()
639                    .map(|(x, mut t)| {
640                        let is_active_col = active_column == x;
641                        let is_current_row = self.is_current(i);
642
643                        if is_current_row && is_active_col {
644                            if self.scroll[1] > 0 {
645                                apply_to_lines(&mut t, |line| hscroll_line(line, self.scroll[1]));
646                            }
647                        }
648
649                        match self.config.row_connection_style {
650                            RowConnectionStyle::Disjoint => {
651                                if is_active_col {
652                                    t = t.style(if is_current_row {
653                                        self.current_style()
654                                    } else {
655                                        self.active_style()
656                                    });
657                                } else {
658                                    t = t.style(if is_current_row {
659                                        self.inactive_current_style()
660                                    } else {
661                                        self.inactive_style()
662                                    });
663                                }
664                            }
665                            RowConnectionStyle::Capped => {
666                                if is_active_col {
667                                    t = t.style(if is_current_row {
668                                        self.current_style()
669                                    } else {
670                                        self.active_style()
671                                    });
672                                }
673                            }
674                            RowConnectionStyle::Full => {}
675                        }
676
677                        // prefix after hscroll
678                        if x == 0 {
679                            prefix_text(&mut t, prefix.clone());
680                        };
681                        t
682                    })
683                    .collect();
684
685                if self.config.right_align_last && row_texts.len() > 1 {
686                    row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
687                }
688
689                // push
690                let mut row = Row::new(row_texts).height(height);
691
692                if self.is_current(i) {
693                    match self.config.row_connection_style {
694                        RowConnectionStyle::Capped => {
695                            row = row.style(self.inactive_current_style())
696                        }
697                        RowConnectionStyle::Full => row = row.style(self.current_style()),
698                        _ => {}
699                    }
700                }
701
702                rows.push(row);
703            } else {
704                let mut push = vec![];
705
706                for (x, mut col) in row.into_iter().enumerate() {
707                    let mut height = col.height() as u16;
708
709                    if remaining_height == 0 {
710                        break;
711                    } else if remaining_height < height {
712                        height = remaining_height;
713                        clip_text_lines(&mut col, remaining_height, self.reverse());
714                    }
715                    remaining_height -= height;
716
717                    if self.is_current(i) && self.scroll[1] > 0 && active_column == x {
718                        apply_to_lines(&mut col, |line| hscroll_line(line, self.scroll[1]));
719                    }
720                    if self.is_current(i) && self.scroll[0] > 0 && active_column == x {
721                        let scroll = self.scroll[0] as usize;
722
723                        if scroll < col.lines.len() {
724                            col.lines = col.lines.split_off(scroll);
725                        } else {
726                            col.lines.clear();
727                        }
728                    }
729
730                    prefix_text(&mut col, prefix.clone());
731
732                    let is_active_col = active_column == x;
733                    let is_current_row = self.is_current(i);
734
735                    match self.config.row_connection_style {
736                        RowConnectionStyle::Disjoint => {
737                            if is_active_col {
738                                col = col.style(if is_current_row {
739                                    self.current_style()
740                                } else {
741                                    self.active_style()
742                                });
743                            } else {
744                                col = col.style(if is_current_row {
745                                    self.inactive_current_style()
746                                } else {
747                                    self.inactive_style()
748                                });
749                            }
750                        }
751                        RowConnectionStyle::Capped => {
752                            if is_active_col {
753                                col = col.style(if is_current_row {
754                                    self.current_style()
755                                } else {
756                                    self.active_style()
757                                });
758                            }
759                        }
760                        RowConnectionStyle::Full => {}
761                    }
762
763                    // push
764                    let mut row = Row::new(vec![col]).height(height);
765                    if is_current_row {
766                        match self.config.row_connection_style {
767                            RowConnectionStyle::Capped => {
768                                row = row.style(self.inactive_current_style())
769                            }
770                            RowConnectionStyle::Full => row = row.style(self.current_style()),
771                            _ => {}
772                        }
773                    }
774                    push.push(row);
775                }
776                rows.extend(push);
777            }
778        }
779
780        if self.reverse() {
781            rows.reverse();
782            if remaining_height > 0 {
783                rows.insert(0, Row::new(vec![vec![]]).height(remaining_height));
784            }
785        }
786
787        // up to the last nonempty row position
788
789        if hz {
790            self.widths = {
791                let pos = widths.iter().rposition(|&x| x != 0).map_or(0, |p| p + 1);
792                let mut widths = widths[..pos].to_vec();
793                if pos > 2 && self.config.right_align_last {
794                    let used = widths.iter().take(widths.len() - 1).sum();
795                    widths[pos - 1] = self.width().saturating_sub(used);
796                }
797                widths
798            };
799        }
800
801        // why does the row highlight apply beyond the table width?
802        let mut table = Table::new(
803            rows,
804            if hz {
805                self.widths.clone()
806            } else {
807                vec![self.width]
808            },
809        )
810        .column_spacing(self.config.column_spacing.0);
811
812        table = match self.config.row_connection_style {
813            RowConnectionStyle::Full => table.style(self.active_style()),
814            RowConnectionStyle::Capped => table.style(self.inactive_style()),
815            _ => table,
816        };
817
818        table = table.block(self.config.border.as_static_block());
819        table
820    }
821}
822
823impl ResultsUI {
824    pub fn make_status(&self, full_width: u16) -> Paragraph<'_> {
825        let status_config = &self.status_config;
826        let replacements = [
827            ('r', self.index().to_string()),
828            ('m', self.status.matched_count.to_string()),
829            ('t', self.status.item_count.to_string()),
830        ];
831
832        // sub replacements into line
833        let mut new_spans = Vec::new();
834
835        if status_config.match_indent {
836            new_spans.push(Span::raw(" ".repeat(self.indentation())));
837        }
838
839        for span in &self.status_template {
840            let subbed = substitute_escaped(&span.content, &replacements);
841            new_spans.push(Span::styled(subbed, span.style));
842        }
843
844        let substituted_line = Line::from(new_spans);
845
846        // sub whitespace expansions
847        let effective_width = match self.status_config.row_connection_style {
848            RowConnectionStyle::Full => full_width,
849            _ => self.width,
850        } as usize;
851        let expanded = expand_indents(substituted_line, r"\s", effective_width)
852            .style(status_config.fg)
853            .add_modifier(status_config.modifier);
854
855        Paragraph::new(expanded)
856    }
857
858    pub fn set_status_line(&mut self, template: Option<Line<'static>>) {
859        let status_config = &self.status_config;
860
861        self.status_template = template
862            .unwrap_or(status_config.template.clone().into())
863            .style(status_config.fg)
864            .add_modifier(status_config.modifier)
865            .into()
866    }
867}
868
869// helpers
870impl ResultsUI {
871    fn default_prefix(&self, i: usize) -> String {
872        let substituted = substitute_escaped(
873            &self.config.default_prefix,
874            &[
875                ('d', &(i + 1).to_string()),                        // cursor index
876                ('r', &(i + 1 + self.bottom as usize).to_string()), // absolute index
877            ],
878        );
879
880        fit_width(&substituted, self.indentation())
881    }
882
883    fn current_style(&self) -> Style {
884        Style::from(self.config.current_fg)
885            .bg(self.config.current_bg)
886            .add_modifier(self.config.current_modifier)
887    }
888
889    fn active_style(&self) -> Style {
890        Style::from(self.config.fg)
891            .bg(self.config.bg)
892            .add_modifier(self.config.modifier)
893    }
894
895    fn inactive_style(&self) -> Style {
896        Style::from(self.config.inactive_fg)
897            .bg(self.config.inactive_bg)
898            .add_modifier(self.config.inactive_modifier)
899    }
900
901    fn inactive_current_style(&self) -> Style {
902        Style::from(self.config.inactive_current_fg)
903            .bg(self.config.inactive_current_bg)
904            .add_modifier(self.config.inactive_current_modifier)
905    }
906
907    fn is_current(&self, i: usize) -> bool {
908        !self.cursor_disabled && self.cursor == i as u16
909    }
910
911    pub fn match_style(&self) -> Style {
912        Style::default()
913            .fg(self.config.match_fg)
914            .add_modifier(self.config.match_modifier)
915    }
916
917    fn hr(&self) -> Option<Row<'static>> {
918        let sep = self.config.horizontal_separator;
919
920        if matches!(sep, HorizontalSeparator::None) {
921            return None;
922        }
923
924        let unit = sep.as_str();
925        let line = unit.repeat(self.width as usize);
926
927        // todo: support non_stacked properly by doing a seperate rendering pass
928        if !self.config.stacked_columns && self.widths.len() > 1 {
929            // Some(Row::new(vec![vec![]]))
930            Some(Row::new(vec![line; self.widths().len()]))
931        } else {
932            Some(Row::new(vec![line]))
933        }
934    }
935
936    fn _hr(&self) -> u16 {
937        !matches!(self.config.horizontal_separator, HorizontalSeparator::None) as u16
938    }
939}
940
941pub struct StatusUI {}
942
943impl StatusUI {
944    pub fn parse_template_to_status_line(s: &str) -> Line<'static> {
945        let parts = match split_on_nesting(&s, ['{', '}']) {
946            Ok(x) => x,
947            Err(n) => {
948                if n > 0 {
949                    log::error!("Encountered {} unclosed parentheses", n)
950                } else {
951                    log::error!("Extra closing parenthesis at index {}", -n)
952                }
953                return Line::from(s.to_string());
954            }
955        };
956
957        let mut spans = Vec::new();
958        let mut in_nested = !s.starts_with('{');
959        for part in parts {
960            in_nested = !in_nested;
961            let content = part.as_str();
962
963            if in_nested {
964                let inner = &content[1..content.len() - 1];
965
966                if let Some((color_name, text)) = inner.split_once(':') {
967                    if let Ok(color) = Color::from_str(color_name) {
968                        spans.push(Span::styled(text.to_string(), Style::default().fg(color)));
969                        continue;
970                    }
971                }
972            }
973
974            spans.push(Span::raw(content.to_string()));
975        }
976
977        Line::from(spans)
978    }
979}