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