Skip to main content

matchmaker/ui/
results.rs

1use cba::bring::split::split_on_nesting;
2use ratatui::{
3    layout::{Alignment, Rect},
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{Paragraph, Row, Table},
7};
8use unicode_width::UnicodeWidthStr;
9
10use crate::{
11    SSS, Selection, Selector,
12    config::{HorizontalSeparator, ResultsConfig, RowConnectionStyle, StatusConfig},
13    nucleo::{Status, Worker},
14    render::Click,
15    utils::{
16        string::{allocate_widths, fit_width, substitute_escaped},
17        text::{clip_text_lines, expand_indents, prefix_text},
18    },
19};
20
21#[derive(Debug)]
22pub struct ResultsUI {
23    cursor: u16,
24    bottom: u32,
25    col: Option<usize>,
26    pub hscroll: i8,
27    pub vscroll: u8,
28
29    /// available height
30    height: u16,
31    /// available width
32    width: u16,
33    // column widths.
34    // Note that the first width doesn't include the indentation.
35    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            hscroll: 0,
58            vscroll: 0,
59
60            widths: Vec::new(),
61            height: 0, // uninitialized, so be sure to call update_dimensions
62            width: 0,
63            hidden_columns: Default::default(),
64
65            status: Default::default(),
66            status_template: Line::from(status_config.template.clone()).style(status_config.style),
67            status_config,
68            config,
69
70            cursor_disabled: false,
71            bottom_clip: None,
72            cursor_above: 0,
73        }
74    }
75
76    pub fn hidden_columns(&mut self, hidden_columns: Vec<bool>) {
77        self.hidden_columns = hidden_columns;
78    }
79
80    // as given by ratatui area
81    pub fn update_dimensions(&mut self, area: &Rect) {
82        let [bw, bh] = [self.config.border.height(), self.config.border.width()];
83        self.width = area.width.saturating_sub(bw);
84        self.height = area.height.saturating_sub(bh);
85        log::debug!("Updated results dimensions: {}x{}", self.width, self.height);
86    }
87
88    pub fn height(&self) -> u16 {
89        self.height
90    }
91
92    // ------ config -------
93    pub fn reverse(&self) -> bool {
94        self.config.reverse == Some(true)
95    }
96    pub fn is_wrap(&self) -> bool {
97        self.config.wrap
98    }
99    pub fn wrap(&mut self, wrap: bool) {
100        self.config.wrap = wrap;
101    }
102
103    // ----- columns --------
104    // todo: support cooler things like only showing/outputting a specific column/cycling columns
105    pub fn toggle_col(&mut self, col_idx: usize) -> bool {
106        self.reset_current_scroll();
107
108        if self.col == Some(col_idx) {
109            self.col = None
110        } else {
111            self.col = Some(col_idx);
112        }
113        self.col.is_some()
114    }
115    pub fn cycle_col(&mut self) {
116        self.reset_current_scroll();
117
118        self.col = match self.col {
119            None => self.widths.is_empty().then_some(0),
120            Some(c) => {
121                let next = c + 1;
122                if next < self.widths.len() {
123                    Some(next)
124                } else {
125                    None
126                }
127            }
128        };
129    }
130
131    // ------- NAVIGATION ---------
132    fn scroll_padding(&self) -> u16 {
133        self.config.scroll_padding.min(self.height / 2)
134    }
135    pub fn end(&self) -> u32 {
136        self.status.matched_count.saturating_sub(1)
137    }
138
139    /// Index in worker snapshot of current item.
140    /// Use with worker.get_nth().
141    //  Equivalently, the cursor progress in the match list
142    pub fn index(&self) -> u32 {
143        if self.cursor_disabled {
144            u32::MAX
145        } else {
146            self.cursor as u32 + self.bottom
147        }
148    }
149    // pub fn cursor(&self) -> Option<u16> {
150    //     if self.cursor_disabled {
151    //         None
152    //     } else {
153    //         Some(self.cursor)
154    //     }
155    // }
156    pub fn cursor_prev(&mut self) {
157        self.reset_current_scroll();
158
159        log::trace!("cursor_prev: {self:?}");
160        if self.cursor_above <= self.scroll_padding() && self.bottom > 0 {
161            self.bottom -= 1;
162            self.bottom_clip = None;
163        } else if self.cursor > 0 {
164            self.cursor -= 1;
165        } else if self.config.scroll_wrap {
166            self.cursor_jump(self.end());
167        }
168    }
169    pub fn cursor_next(&mut self) {
170        self.reset_current_scroll();
171
172        if self.cursor_disabled {
173            self.cursor_disabled = false
174        }
175
176        // log::trace!(
177        //     "Cursor {} @ index {}. Status: {:?}.",
178        //     self.cursor,
179        //     self.index(),
180        //     self.status
181        // );
182        if self.cursor + 1 + self.scroll_padding() >= self.height
183            && self.bottom + (self.height as u32) < self.status.matched_count
184        {
185            self.bottom += 1; //
186        } else if self.index() < self.end() {
187            self.cursor += 1;
188        } else if self.config.scroll_wrap {
189            self.cursor_jump(0)
190        }
191    }
192
193    pub fn cursor_jump(&mut self, index: u32) {
194        self.reset_current_scroll();
195
196        self.cursor_disabled = false;
197        self.bottom_clip = None;
198
199        let end = self.end();
200        let index = index.min(end);
201
202        if index < self.bottom as u32 || index >= self.bottom + self.height as u32 {
203            self.bottom = (end + 1)
204                .saturating_sub(self.height as u32) // don't exceed the first item of the last self.height items
205                .min(index);
206        }
207        self.cursor = (index - self.bottom) as u16;
208        log::debug!("cursor jumped to {}: {index}, end: {end}", self.cursor);
209    }
210
211    pub fn current_scroll(&mut self, x: i8, horizontal: bool) {
212        if horizontal {
213            self.hscroll = if x == 0 {
214                0
215            } else {
216                self.hscroll.saturating_add(x)
217            };
218        } else {
219            self.vscroll = if x == 0 {
220                0
221            } else if x.is_negative() {
222                self.vscroll.saturating_add(x.unsigned_abs())
223            } else {
224                self.vscroll.saturating_sub(x as u8)
225            };
226        }
227    }
228
229    pub fn reset_current_scroll(&mut self) {
230        self.hscroll = 0;
231        self.vscroll = 0;
232    }
233
234    // ------- RENDERING ----------
235    pub fn indentation(&self) -> usize {
236        self.config.multi_prefix.width()
237    }
238    pub fn col(&self) -> Option<usize> {
239        self.col
240    }
241
242    /// Column widths.
243    /// Note that the first width doesn't include the indentation.
244    pub fn widths(&self) -> &Vec<u16> {
245        &self.widths
246    }
247
248    /// Adapt the stored widths (initialized by [`Worker::results`]) to the fit within the available width (self.width)
249    /// widths <= min_wrap_width don't shrink and aren't wrapped
250    pub fn max_widths(&self) -> Vec<u16> {
251        let mut base_widths = self.widths.clone();
252
253        if base_widths.is_empty() {
254            return base_widths;
255        }
256        base_widths.resize(self.hidden_columns.len().max(base_widths.len()), 0);
257
258        for (i, is_hidden) in self.hidden_columns.iter().enumerate() {
259            if *is_hidden {
260                base_widths[i] = 0;
261            }
262        }
263
264        let target = self.content_width();
265        let sum: u16 = base_widths
266            .iter()
267            .map(|x| {
268                (*x != 0)
269                    .then_some(*x.max(&self.config.min_wrap_width))
270                    .unwrap_or_default()
271            })
272            .sum();
273
274        if sum < target {
275            let nonzero_count = base_widths.iter().filter(|w| **w > 0).count();
276            if nonzero_count > 0 {
277                let extra_per_column = (target - sum) / nonzero_count as u16;
278                let mut remainder = (target - sum) % nonzero_count as u16;
279
280                for w in base_widths.iter_mut().filter(|w| **w > 0) {
281                    *w += extra_per_column;
282                    if remainder > 0 {
283                        *w += 1;
284                        remainder -= 1;
285                    }
286                }
287            }
288        }
289
290        match allocate_widths(&base_widths, target, self.config.min_wrap_width) {
291            Ok(s) | Err(s) => s,
292        }
293    }
294
295    pub fn content_width(&self) -> u16 {
296        self.width
297            .saturating_sub(self.indentation() as u16)
298            .saturating_sub(self.column_spacing_width())
299    }
300
301    pub fn column_spacing_width(&self) -> u16 {
302        let pos = self.widths.iter().rposition(|&x| x != 0);
303        self.config.column_spacing.0 * (pos.unwrap_or_default() as u16)
304    }
305
306    pub fn table_width(&self) -> u16 {
307        if self.config.stacked_columns {
308            self.width
309        } else {
310            self.widths.iter().sum::<u16>()
311                + self.config.border.width()
312                + self.indentation() as u16
313                + self.column_spacing_width()
314        }
315    }
316
317    // this updates the internal status, so be sure to call make_status afterward
318    // some janky wrapping is implemented, dunno whats causing flickering, padding is fixed going down only
319    pub fn make_table<'a, T: SSS>(
320        &mut self,
321        active_column: usize,
322        worker: &'a mut Worker<T>,
323        selector: &mut Selector<T, impl Selection>,
324        matcher: &mut nucleo::Matcher,
325        click: &mut Click,
326    ) -> Table<'a> {
327        let offset = self.bottom as u32;
328        let end = self.bottom + self.height as u32;
329        let hz = !self.config.stacked_columns;
330
331        let width_limits = if hz {
332            self.max_widths()
333        } else {
334            let default = self.width.saturating_sub(self.indentation() as u16);
335
336            (0..worker.columns.len())
337                .map(|i| {
338                    if self.hidden_columns.get(i).copied().unwrap_or(false) {
339                        0
340                    } else {
341                        default
342                    }
343                })
344                .collect()
345        };
346
347        let (mut results, mut widths, status) = worker.results(
348            offset,
349            end,
350            &width_limits,
351            self.config.wrap,
352            self.config.max_height,
353            self.config.match_style.into(),
354            matcher,
355            self.config.autoscroll,
356            self.hscroll,
357            (
358                if self.config.vscroll_current_only {
359                    0
360                } else {
361                    self.vscroll
362                },
363                hz,
364            ),
365            self.config.show_skipped,
366        );
367
368        widths[0] += self.indentation() as u16;
369        // should generally be true already, but act as a safeguard
370        for x in widths.iter_mut().zip(&self.hidden_columns) {
371            if *x.1 {
372                *x.0 = 0
373            }
374        }
375        let widths = widths;
376
377        let match_count = status.matched_count;
378        self.status = status;
379
380        if match_count < self.bottom + self.cursor as u32 && !self.cursor_disabled {
381            self.cursor_jump(match_count);
382        } else {
383            self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
384        }
385
386        let mut rows = vec![];
387        let mut total_height = 0;
388
389        if results.is_empty() {
390            return Table::new(rows, widths);
391        }
392
393        let height_of = |t: &(Vec<ratatui::text::Text<'a>>, _)| {
394            self._hr()
395                + if hz {
396                    t.0.iter()
397                        .map(|t| t.height() as u16)
398                        .max()
399                        .unwrap_or_default()
400                } else {
401                    t.0.iter().map(|t| t.height() as u16).sum::<u16>()
402                }
403        };
404
405        // log::debug!("results initial: {}, {}, {}, {}, {}", self.bottom, self.cursor, total_height, self.height, results.len());
406        let h_at_cursor = height_of(&results[self.cursor as usize]);
407        let h_after_cursor = results[self.cursor as usize + 1..]
408            .iter()
409            .map(height_of)
410            .sum();
411        let h_to_cursor = results[0..self.cursor as usize]
412            .iter()
413            .map(height_of)
414            .sum::<u16>();
415        let cursor_end_should_lte = self.height - self.scroll_padding().min(h_after_cursor);
416        // let cursor_start_should_gt = self.scroll_padding().min(h_to_cursor);
417
418        // log::debug!(
419        //     "Computed heights: {}, {h_at_cursor}, {h_to_cursor}, {h_after_cursor}, {cursor_end_should_lte}",
420        //     self.cursor
421        // );
422
423        // begin adjustment
424        let mut start_index = 0; // the index in results of the first complete item
425
426        if h_at_cursor >= cursor_end_should_lte {
427            start_index = self.cursor;
428            self.bottom += self.cursor as u32;
429            self.cursor = 0;
430            self.cursor_above = 0;
431            self.bottom_clip = None;
432        } else
433        // increase the bottom index so that cursor_should_above is maintained
434        if let h_to_cursor_end = h_to_cursor + h_at_cursor
435            && h_to_cursor_end > cursor_end_should_lte
436        {
437            let mut trunc_height = h_to_cursor_end - cursor_end_should_lte;
438            // 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
439
440            for r in results[start_index as usize..self.cursor as usize].iter_mut() {
441                let h = height_of(r);
442                let (row, item) = r;
443                start_index += 1; // we always skip at least the first item
444
445                if trunc_height < h {
446                    let mut remaining_height = h - trunc_height;
447                    let prefix = if selector.contains(item) {
448                        self.config.multi_prefix.clone().to_string()
449                    } else {
450                        self.default_prefix(0)
451                    };
452
453                    total_height += remaining_height;
454
455                    // log::debug!("r: {remaining_height}");
456                    if hz {
457                        if h - self._hr() < remaining_height {
458                            for (_, t) in
459                                row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0)
460                            {
461                                clip_text_lines(t, remaining_height, !self.reverse());
462                            }
463                        }
464
465                        prefix_text(&mut row[0], prefix);
466
467                        let last_visible = widths
468                            .iter()
469                            .enumerate()
470                            .rev()
471                            .find_map(|(i, w)| (*w != 0).then_some(i));
472
473                        let mut row_texts: Vec<_> = row
474                            .iter()
475                            .take(last_visible.map(|x| x + 1).unwrap_or(0))
476                            .cloned()
477                            .collect();
478
479                        if self.config.right_align_last && row_texts.len() > 1 {
480                            row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
481                        }
482
483                        let row = Row::new(row_texts).height(remaining_height);
484                        rows.push(row);
485                    } else {
486                        let mut push = vec![];
487
488                        for col in row.into_iter().rev() {
489                            let mut height = col.height() as u16;
490                            if remaining_height == 0 {
491                                break;
492                            } else if remaining_height < height {
493                                clip_text_lines(col, remaining_height, !self.reverse());
494                                height = remaining_height;
495                            }
496                            remaining_height -= height;
497                            prefix_text(col, prefix.clone());
498                            push.push(Row::new(vec![col.clone()]).height(height));
499                        }
500                        rows.extend(push.into_iter().rev());
501                    }
502
503                    self.bottom += start_index as u32 - 1;
504                    self.cursor -= start_index - 1;
505                    self.bottom_clip = Some(remaining_height);
506                    break;
507                } else if trunc_height == h {
508                    self.bottom += start_index as u32;
509                    self.cursor -= start_index;
510                    self.bottom_clip = None;
511                    break;
512                }
513
514                trunc_height -= h;
515            }
516        } else if let Some(mut remaining_height) = self.bottom_clip {
517            start_index += 1;
518            // same as above
519            let h = height_of(&results[0]);
520            let (row, item) = &mut results[0];
521            let prefix = if selector.contains(item) {
522                self.config.multi_prefix.clone().to_string()
523            } else {
524                self.default_prefix(0)
525            };
526
527            total_height += remaining_height;
528
529            if hz {
530                if self._hr() + remaining_height != h {
531                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
532                        clip_text_lines(t, remaining_height, !self.reverse());
533                    }
534                }
535
536                prefix_text(&mut row[0], prefix);
537
538                let last_visible = widths
539                    .iter()
540                    .enumerate()
541                    .rev()
542                    .find_map(|(i, w)| (*w != 0).then_some(i));
543
544                let mut row_texts: Vec<_> = row
545                    .iter()
546                    .take(last_visible.map(|x| x + 1).unwrap_or(0))
547                    .cloned()
548                    .collect();
549
550                if self.config.right_align_last && row_texts.len() > 1 {
551                    row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
552                }
553
554                let row = Row::new(row_texts).height(remaining_height);
555                rows.push(row);
556            } else {
557                let mut push = vec![];
558
559                for col in row.into_iter().rev() {
560                    let mut height = col.height() as u16;
561                    if remaining_height == 0 {
562                        break;
563                    } else if remaining_height < height {
564                        clip_text_lines(col, remaining_height, !self.reverse());
565                        height = remaining_height;
566                    }
567                    remaining_height -= height;
568                    prefix_text(col, prefix.clone());
569                    push.push(Row::new(vec![col.clone()]).height(height));
570                }
571                rows.extend(push.into_iter().rev());
572            }
573        }
574
575        // topside padding is not self-correcting, and can only do its best to stay at #padding lines without obscuring cursor on cursor movement events.
576        let mut remaining_height = self.height.saturating_sub(total_height);
577
578        for (mut i, (mut row, item)) in results.drain(start_index as usize..).enumerate() {
579            i += self.bottom_clip.is_some() as usize;
580
581            // this is technically one step out of sync but idc
582            if let Click::ResultPos(c) = click
583                && self.height - remaining_height > *c
584            {
585                let idx = self.bottom as u32 + i as u32 - 1;
586                log::debug!("Mapped click position to index: {c} -> {idx}",);
587                *click = Click::ResultIdx(idx);
588            }
589            if self.is_current(i) {
590                self.cursor_above = self.height - remaining_height;
591            }
592
593            // insert hr
594            if let Some(hr) = self.hr()
595                && remaining_height > 0
596            {
597                rows.push(hr);
598                remaining_height -= self._hr();
599            }
600            if remaining_height == 0 {
601                break;
602            }
603
604            // determine prefix
605            let prefix = if selector.contains(item) {
606                self.config.multi_prefix.clone().to_string()
607            } else {
608                self.default_prefix(i)
609            };
610
611            if hz {
612                // scroll down
613                if self.is_current(i) && self.config.vscroll_current_only && self.vscroll > 0 {
614                    for (x, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
615                        if self.col.is_none() || self.col() == Some(x) {
616                            let scroll = self.vscroll as usize;
617
618                            if scroll < t.lines.len() {
619                                t.lines = t.lines.split_off(scroll);
620                            } else {
621                                t.lines.clear();
622                            }
623                        }
624                    }
625                }
626
627                let mut height = row
628                    .iter()
629                    .map(|t| t.height() as u16)
630                    .max()
631                    .unwrap_or_default();
632
633                if remaining_height < height {
634                    height = remaining_height;
635
636                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
637                        clip_text_lines(t, height, self.reverse());
638                    }
639                }
640                remaining_height -= height;
641
642                // same as above
643                let last_visible = widths
644                    .iter()
645                    .enumerate()
646                    .rev()
647                    .find_map(|(i, w)| (*w != 0).then_some(i));
648
649                let mut row_texts: Vec<_> = row
650                    .iter()
651                    .take(last_visible.map(|x| x + 1).unwrap_or(0))
652                    .cloned()
653                    // highlight
654                    .enumerate()
655                    .map(|(x, mut t)| {
656                        let is_active_col = active_column == x;
657                        let is_current_row = self.is_current(i);
658
659                        if is_current_row && is_active_col {
660                            // NOTE: hscroll is handled in worker.results -> render_cell
661                        }
662
663                        match self.config.row_connection {
664                            RowConnectionStyle::Disjoint => {
665                                if is_active_col {
666                                    t = t.style(if is_current_row {
667                                        self.config.current
668                                    } else {
669                                        self.config.style
670                                    });
671                                } else {
672                                    t = t.style(if is_current_row {
673                                        self.config.inactive_current
674                                    } else {
675                                        self.config.inactive
676                                    });
677                                }
678                            }
679                            RowConnectionStyle::Capped => {
680                                if is_active_col {
681                                    t = t.style(if is_current_row {
682                                        self.config.current
683                                    } else {
684                                        self.config.style
685                                    });
686                                }
687                            }
688                            RowConnectionStyle::Full => {}
689                        }
690
691                        // prefix after hscroll
692                        if x == 0 {
693                            prefix_text(&mut t, prefix.clone());
694                        };
695                        t
696                    })
697                    .collect();
698
699                if self.config.right_align_last && row_texts.len() > 1 {
700                    row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
701                }
702
703                // push
704                let mut row = Row::new(row_texts).height(height);
705
706                if self.is_current(i) {
707                    match self.config.row_connection {
708                        RowConnectionStyle::Capped => row = row.style(self.config.inactive_current),
709                        RowConnectionStyle::Full => row = row.style(self.config.current),
710                        _ => {}
711                    }
712                }
713
714                rows.push(row);
715            } else {
716                let mut push = vec![];
717                let mut vscroll_to_skip = if self.is_current(i) && self.config.vscroll_current_only
718                {
719                    self.vscroll as usize
720                } else {
721                    0
722                };
723
724                for (x, mut col) in row.into_iter().enumerate() {
725                    if vscroll_to_skip > 0 {
726                        let col_height = col.lines.len();
727                        if vscroll_to_skip >= col_height {
728                            vscroll_to_skip -= col_height;
729                            continue;
730                        } else {
731                            col.lines = col.lines.split_off(vscroll_to_skip);
732                            vscroll_to_skip = 0;
733                        }
734                    }
735
736                    let mut height = col.height() as u16;
737
738                    if remaining_height == 0 {
739                        break;
740                    } else if remaining_height < height {
741                        height = remaining_height;
742                        clip_text_lines(&mut col, remaining_height, self.reverse());
743                    }
744                    remaining_height -= height;
745
746                    prefix_text(&mut col, prefix.clone());
747
748                    let is_active_col = active_column == x;
749                    let is_current_row = self.is_current(i);
750
751                    match self.config.row_connection {
752                        RowConnectionStyle::Disjoint => {
753                            if is_active_col {
754                                col = col.style(if is_current_row {
755                                    self.config.current
756                                } else {
757                                    self.config.style
758                                });
759                            } else {
760                                col = col.style(if is_current_row {
761                                    self.config.inactive_current
762                                } else {
763                                    self.config.inactive
764                                });
765                            }
766                        }
767                        RowConnectionStyle::Capped => {
768                            if is_active_col {
769                                col = col.style(if is_current_row {
770                                    self.config.current
771                                } else {
772                                    self.config.style
773                                });
774                            }
775                        }
776                        RowConnectionStyle::Full => {}
777                    }
778
779                    // push
780                    let mut row = Row::new(vec![col]).height(height);
781                    if is_current_row {
782                        match self.config.row_connection {
783                            RowConnectionStyle::Capped => {
784                                row = row.style(self.config.inactive_current)
785                            }
786                            RowConnectionStyle::Full => row = row.style(self.config.current),
787                            _ => {}
788                        }
789                    }
790                    push.push(row);
791                }
792                rows.extend(push);
793            }
794        }
795
796        if self.reverse() {
797            rows.reverse();
798            if remaining_height > 0 {
799                rows.insert(0, Row::new(vec![vec![]]).height(remaining_height));
800            }
801        }
802
803        // ratatui column_spacing eats into the constraints
804        let table_widths = if hz {
805            // first 0 element after which all is 0
806            let pos = widths.iter().rposition(|&x| x != 0);
807            // column_spacing eats into the width
808            let mut widths: Vec<_> = widths[..pos.map_or(0, |x| x + 1)].to_vec();
809            if let Some(pos) = pos
810                && pos > 0
811                && self.config.right_align_last
812            {
813                let used = widths.iter().take(widths.len() - 1).sum();
814                widths[pos] = self.width.saturating_sub(used);
815            }
816            if let Some(s) = widths.get_mut(0) {
817                *s -= self.indentation() as u16
818            }
819            self.widths = widths.clone();
820
821            if !self.config.wrap {
822                widths
823                    .iter_mut()
824                    .zip(width_limits.iter())
825                    .for_each(|(w, &limit)| {
826                        *w = (*w).min(limit);
827                    });
828            }
829
830            if let Some(s) = widths.get_mut(0) {
831                *s += self.indentation() as u16;
832            }
833
834            let surplus = self.width.saturating_sub(widths.iter().sum());
835
836            if surplus > 0 && matches!(self.config.row_connection, RowConnectionStyle::Full)
837                || (matches!(self.config.row_connection, RowConnectionStyle::Disjoint)
838                    && self.config.right_align_last)
839            {
840                if let Some(s) = widths.last_mut() {
841                    *s += surplus;
842                }
843            }
844
845            widths
846        } else {
847            vec![self.width]
848        };
849
850        // log::debug!(
851        //     "widths: {width_limits:?}, {widths:?}, {table_widths:?}, {:?}",
852        //     self.widths
853        // );
854
855        let mut table = Table::new(rows, table_widths).column_spacing(self.config.column_spacing.0);
856
857        table = match self.config.row_connection {
858            RowConnectionStyle::Full => table.style(self.config.style),
859            RowConnectionStyle::Capped => table.style(self.config.inactive),
860            _ => table,
861        };
862
863        table = table.block(self.config.border.as_static_block());
864        table
865    }
866}
867
868impl ResultsUI {
869    pub fn make_status(&self, full_width: u16) -> Paragraph<'_> {
870        let status_config = &self.status_config;
871        let replacements = [
872            ('r', self.index().to_string()),
873            ('m', self.status.matched_count.to_string()),
874            ('t', self.status.item_count.to_string()),
875        ];
876
877        // sub replacements into line
878        let mut new_spans = Vec::new();
879
880        if status_config.match_indent {
881            new_spans.push(Span::raw(" ".repeat(self.indentation())));
882        }
883
884        for span in &self.status_template {
885            let subbed = substitute_escaped(&span.content, &replacements);
886            new_spans.push(Span::styled(subbed, span.style));
887        }
888
889        let substituted_line = Line::from(new_spans);
890
891        // sub whitespace expansions
892        let effective_width = match self.status_config.row_connection {
893            RowConnectionStyle::Full => full_width,
894            _ => self.width,
895        } as usize;
896        let expanded = expand_indents(substituted_line, r"\s", r"\S", effective_width)
897            .style(status_config.style);
898
899        Paragraph::new(expanded)
900    }
901
902    /// The style from the config overrides the Line style (but not the span styles).
903    pub fn set_status_line(&mut self, template: Option<Line<'static>>) {
904        let status_config = &self.status_config;
905        log::trace!("status line: {template:?}");
906
907        self.status_template = template
908            .unwrap_or(status_config.template.clone().into())
909            .style(status_config.style)
910            .into()
911    }
912}
913
914// helpers
915impl ResultsUI {
916    fn default_prefix(&self, i: usize) -> String {
917        let substituted = substitute_escaped(
918            &self.config.default_prefix,
919            &[
920                ('d', &(i + 1).to_string()),                        // cursor index
921                ('r', &(i + 1 + self.bottom as usize).to_string()), // absolute index
922            ],
923        );
924
925        fit_width(&substituted, self.indentation())
926    }
927
928    fn is_current(&self, i: usize) -> bool {
929        !self.cursor_disabled && self.cursor == i as u16
930    }
931
932    fn hr(&self) -> Option<Row<'static>> {
933        let sep = self.config.separator;
934
935        if matches!(sep, HorizontalSeparator::None) {
936            return None;
937        }
938
939        let unit = sep.as_str();
940        let line = unit.repeat(self.width as usize);
941
942        // todo: support non_stacked properly by doing a seperate rendering pass
943        if !self.config.stacked_columns && self.widths.len() > 1 {
944            // Some(Row::new(vec![vec![]]))
945            Some(Row::new(vec![line; self.widths().len()]).style(self.config.separator_style))
946        } else {
947            Some(Row::new(vec![line]).style(self.config.separator_style))
948        }
949    }
950
951    fn _hr(&self) -> u16 {
952        !matches!(self.config.separator, HorizontalSeparator::None) as u16
953    }
954}
955
956pub struct StatusUI {}
957
958impl StatusUI {
959    pub fn parse_template_to_status_line(s: &str) -> Line<'static> {
960        let parts = match split_on_nesting(&s, ['{', '}']) {
961            Ok(x) => x,
962            Err(n) => {
963                if n > 0 {
964                    log::error!("Encountered {} unclosed parentheses", n)
965                } else {
966                    log::error!("Extra closing parenthesis at index {}", -n)
967                }
968                return Line::from(s.to_string());
969            }
970        };
971
972        let mut spans = Vec::new();
973        let mut in_nested = !s.starts_with('{');
974        for part in parts {
975            in_nested = !in_nested;
976            let content = part.as_str();
977
978            if in_nested {
979                let inner = &content[1..content.len() - 1];
980
981                // perform replacement fg:content
982                spans.push(Self::span_from_template(inner));
983            } else {
984                spans.push(Span::raw(content.to_string()));
985            }
986        }
987
988        Line::from(spans)
989    }
990
991    /// Converts a template string into a `Span` with colors and modifiers.
992    ///
993    /// The template string format is:
994    /// ```text
995    /// "style1,style2,...:text"
996    /// ```
997    /// - The **first valid color** token is used as foreground (fg).
998    /// - The **second valid color** token is used as background (bg).
999    /// - Remaining tokens are interpreted as **modifiers**: bold, dim, italic, underlined,
1000    ///   slow_blink, rapid_blink, reversed, hidden, crossed_out.
1001    /// - Empty tokens are ignored.
1002    /// - Unrecognized tokens are collected and logged once at the end.
1003    ///
1004    /// # Examples
1005    ///
1006    /// ```
1007    /// use matchmaker::ui::StatusUI;
1008    /// StatusUI::span_from_template("red,bg=blue,bold,italic:Hello");
1009    /// StatusUI::span_from_template("green,,underlined:World");
1010    /// StatusUI::span_from_template(",,dim:OnlyDim");
1011    /// ```
1012    ///
1013    /// Returns a `Span` with the specified styles applied to the text.
1014    pub fn span_from_template(inner: &str) -> Span<'static> {
1015        use std::str::FromStr;
1016
1017        let (style_part, text) = inner.split_once(':').unwrap_or(("", inner));
1018
1019        let mut style = Style::default();
1020        let mut fg_set = false;
1021        let mut bg_set = false;
1022        let mut unknown_tokens = Vec::new();
1023
1024        for token in style_part.split(',') {
1025            let token = token.trim();
1026            if token.is_empty() {
1027                fg_set = true;
1028                continue;
1029            }
1030
1031            if !fg_set {
1032                if let Ok(color) = Color::from_str(token) {
1033                    style = style.fg(color);
1034                    fg_set = true;
1035                    continue;
1036                }
1037            }
1038
1039            if !bg_set {
1040                if let Ok(color) = Color::from_str(token) {
1041                    style = style.bg(color);
1042                    bg_set = true;
1043                    continue;
1044                }
1045            }
1046
1047            match token.to_lowercase().as_str() {
1048                "bold" => {
1049                    style = style.add_modifier(Modifier::BOLD);
1050                }
1051                "dim" => {
1052                    style = style.add_modifier(Modifier::DIM);
1053                }
1054                "italic" => {
1055                    style = style.add_modifier(Modifier::ITALIC);
1056                }
1057                "underlined" => {
1058                    style = style.add_modifier(Modifier::UNDERLINED);
1059                }
1060                "slow_blink" => {
1061                    style = style.add_modifier(Modifier::SLOW_BLINK);
1062                }
1063                "rapid_blink" => {
1064                    style = style.add_modifier(Modifier::RAPID_BLINK);
1065                }
1066                "reversed" => {
1067                    style = style.add_modifier(Modifier::REVERSED);
1068                }
1069                "hidden" => {
1070                    style = style.add_modifier(Modifier::HIDDEN);
1071                }
1072                "crossed_out" => {
1073                    style = style.add_modifier(Modifier::CROSSED_OUT);
1074                }
1075                _ => unknown_tokens.push(token.to_string()),
1076            };
1077        }
1078
1079        if !unknown_tokens.is_empty() {
1080            log::warn!("Unknown style tokens: {:?}", unknown_tokens);
1081        }
1082
1083        Span::styled(text.to_string(), style)
1084    }
1085}