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