Skip to main content

matchmaker/ui/
results.rs

1use cli_boilerplate_automation::bring::StrExt;
2use ratatui::{
3    layout::{Alignment, Rect},
4    style::{Style, Stylize},
5    widgets::{Paragraph, Row, Table},
6};
7use unicode_width::UnicodeWidthStr;
8
9use crate::{
10    SSS, Selection, Selector,
11    config::{HorizontalSeparator, ResultsConfig, RowConnectionStyle, StatusConfig},
12    nucleo::{Status, Worker},
13    render::Click,
14    utils::{
15        string::{fit_width, substitute_escaped},
16        text::{clip_text_lines, prefix_text},
17    },
18};
19
20#[derive(Debug)]
21pub struct ResultsUI {
22    cursor: u16,
23    bottom: u16,
24    height: u16, // actual height
25    width: u16,
26    // column widths.
27    // Note that the first width includes the indentation.
28    widths: Vec<u16>,
29    col: Option<usize>,
30    pub status: Status,
31    pub config: ResultsConfig,
32    pub status_config: StatusConfig,
33
34    pub bottom_clip: Option<u16>,
35    pub cursor_above: u16,
36
37    pub cursor_disabled: bool,
38}
39
40impl ResultsUI {
41    pub fn new(config: ResultsConfig, status_config: StatusConfig) -> Self {
42        Self {
43            cursor: 0,
44            bottom: 0,
45            col: None,
46            widths: Vec::new(),
47            status: Default::default(),
48            height: 0, // uninitialized, so be sure to call update_dimensions
49            width: 0,
50            config,
51            status_config,
52            cursor_disabled: false,
53            bottom_clip: None,
54            cursor_above: 0,
55        }
56    }
57    // as given by ratatui area
58    pub fn update_dimensions(&mut self, area: &Rect) {
59        let [bw, bh] = [self.config.border.height(), self.config.border.width()];
60        self.width = area.width.saturating_sub(bw);
61        self.height = area.height.saturating_sub(bh);
62        log::debug!("Updated results dimensions: {}x{}", self.width, self.height);
63    }
64
65    pub fn table_width(&self) -> u16 {
66        self.config.column_spacing.0 * self.widths().len().saturating_sub(1) as u16
67            + self.widths.iter().sum::<u16>()
68            + self.config.border.width()
69    }
70
71    // ------ config -------
72    pub fn reverse(&self) -> bool {
73        self.config.reverse == Some(true)
74    }
75    pub fn is_wrap(&self) -> bool {
76        self.config.wrap
77    }
78    pub fn wrap(&mut self, wrap: bool) {
79        self.config.wrap = wrap;
80    }
81
82    // ----- columns --------
83    // todo: support cooler things like only showing/outputting a specific column/cycling columns
84    pub fn toggle_col(&mut self, col_idx: usize) -> bool {
85        if self.col == Some(col_idx) {
86            self.col = None
87        } else {
88            self.col = Some(col_idx);
89        }
90        self.col.is_some()
91    }
92    pub fn cycle_col(&mut self) {
93        self.col = match self.col {
94            None => self.widths.is_empty().then_some(0),
95            Some(c) => {
96                let next = c + 1;
97                if next < self.widths.len() {
98                    Some(next)
99                } else {
100                    None
101                }
102            }
103        };
104    }
105
106    // ------- NAVIGATION ---------
107    fn scroll_padding(&self) -> u16 {
108        self.config.scroll_padding.min(self.height / 2)
109    }
110    pub fn end(&self) -> u32 {
111        self.status.matched_count.saturating_sub(1)
112    }
113
114    /// Index in worker snapshot of current item.
115    /// Use with worker.get_nth().
116    //  Equivalently, the cursor progress in the match list
117    pub fn index(&self) -> u32 {
118        if self.cursor_disabled {
119            u32::MAX
120        } else {
121            (self.cursor + self.bottom) as u32
122        }
123    }
124    // pub fn cursor(&self) -> Option<u16> {
125    //     if self.cursor_disabled {
126    //         None
127    //     } else {
128    //         Some(self.cursor)
129    //     }
130    // }
131    pub fn cursor_prev(&mut self) {
132        if self.cursor_above <= self.scroll_padding() && self.bottom > 0 {
133            self.bottom -= 1;
134            self.bottom_clip = None;
135        } else if self.cursor > 0 {
136            self.cursor -= 1;
137        } else if self.config.scroll_wrap {
138            self.cursor_jump(self.end());
139        }
140    }
141    pub fn cursor_next(&mut self) {
142        if self.cursor_disabled {
143            self.cursor_disabled = false
144        }
145
146        // log::trace!(
147        //     "Cursor {} @ index {}. Status: {:?}.",
148        //     self.cursor,
149        //     self.index(),
150        //     self.status
151        // );
152        if self.cursor + 1 + self.scroll_padding() >= self.height
153            && self.bottom + self.height < self.status.matched_count as u16
154        {
155            self.bottom += 1; // 
156        } else if self.index() < self.end() {
157            self.cursor += 1;
158        } else if self.config.scroll_wrap {
159            self.cursor_jump(0)
160        }
161    }
162
163    pub fn cursor_jump(&mut self, index: u32) {
164        self.cursor_disabled = false;
165        self.bottom_clip = None;
166
167        let end = self.end();
168        let index = index.min(end) as u16;
169
170        if index < self.bottom || index >= self.bottom + self.height {
171            self.bottom = (end as u16 + 1)
172                .saturating_sub(self.height) // don't exceed the first item of the last self.height items
173                .min(index);
174            self.cursor = index - self.bottom;
175        } else {
176            self.cursor = index - self.bottom;
177        }
178    }
179
180    // ------- RENDERING ----------
181    pub fn indentation(&self) -> usize {
182        self.config.multi_prefix.width()
183    }
184    pub fn col(&self) -> Option<usize> {
185        self.col
186    }
187
188    /// Column widths.
189    /// Note that the first width includes the indentation.
190    pub fn widths(&self) -> &Vec<u16> {
191        &self.widths
192    }
193    // results width
194    pub fn width(&self) -> u16 {
195        self.width.saturating_sub(self.indentation() as u16)
196    }
197
198    /// Adapt the stored widths (initialized by [`Worker::results`]) to the fit within the available width (self.width)
199    pub fn max_widths(&self) -> Vec<u16> {
200        if !self.config.wrap {
201            return vec![];
202        }
203
204        let mut widths = vec![u16::MAX; self.widths.len()];
205
206        let total: u16 = self.widths.iter().sum();
207        if total <= self.width() {
208            return vec![];
209        }
210
211        let mut available = self.width();
212        let mut scale_total = 0;
213        let mut scalable_indices = Vec::new();
214
215        for (i, &w) in self.widths.iter().enumerate() {
216            if w <= self.config.min_wrap_width {
217                available = available.saturating_sub(w);
218            } else {
219                scale_total += w;
220                scalable_indices.push(i);
221            }
222        }
223
224        for &i in &scalable_indices {
225            let old = self.widths[i];
226            let new_w = old * available / scale_total;
227            widths[i] = new_w.max(self.config.min_wrap_width);
228        }
229
230        // give remainder to the last scalable column
231        if let Some(&last_idx) = scalable_indices.last() {
232            let used_total: u16 = widths.iter().sum();
233            if used_total < self.width() {
234                widths[last_idx] += self.width() - used_total;
235            }
236        }
237
238        widths
239    }
240
241    // this updates the internal status, so be sure to call make_status afterward
242    // some janky wrapping is implemented, dunno whats causing flickering, padding is fixed going down only
243    pub fn make_table<'a, T: SSS>(
244        &mut self,
245        worker: &'a mut Worker<T>,
246        selector: &mut Selector<T, impl Selection>,
247        matcher: &mut nucleo::Matcher,
248        click: &mut Click,
249    ) -> Table<'a> {
250        let offset = self.bottom as u32;
251        let end = (self.bottom + self.height) as u32;
252        let hz = !self.config.stacked_columns;
253
254        let width_limits = if hz {
255            self.max_widths()
256        } else {
257            vec![
258                if self.config.wrap {
259                    self.width
260                } else {
261                    u16::MAX
262                };
263                worker.columns.len()
264            ]
265        };
266
267        let (mut results, mut widths, status) =
268            worker.results(offset, end, &width_limits, self.match_style(), matcher);
269
270        let match_count = status.matched_count;
271        self.status = status;
272
273        if match_count < (self.bottom + self.cursor) as u32 && !self.cursor_disabled {
274            self.cursor_jump(match_count);
275        } else {
276            self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
277        }
278
279        widths[0] += self.indentation() as u16;
280
281        let mut rows = vec![];
282        let mut total_height = 0;
283
284        if results.is_empty() {
285            return Table::new(rows, widths);
286        }
287
288        let height_of = |t: &(Vec<ratatui::text::Text<'a>>, _)| {
289            self._hr()
290                + if hz {
291                    t.0.iter()
292                        .map(|t| t.height() as u16)
293                        .max()
294                        .unwrap_or_default()
295                } else {
296                    t.0.iter().map(|t| t.height() as u16).sum::<u16>()
297                }
298        };
299
300        // log::debug!("results initial: {}, {}, {}, {}, {}", self.bottom, self.cursor, total_height, self.height, results.len());
301        let h_at_cursor = height_of(&results[self.cursor as usize]);
302        let h_after_cursor = results[self.cursor as usize + 1..]
303            .iter()
304            .map(height_of)
305            .sum();
306        let h_to_cursor = results[0..self.cursor as usize]
307            .iter()
308            .map(height_of)
309            .sum::<u16>();
310        let cursor_end_should_lt = self.height - self.scroll_padding().min(h_after_cursor);
311        // let cursor_start_should_gt = self.scroll_padding().min(h_to_cursor);
312
313        // log::debug!(
314        //     "Computed heights: {h_at_cursor}, {h_to_cursor}, {h_after_cursor}, {cursor_end_should_lt}",
315        // );
316        // begin adjustment
317        let mut start_index = 0; // the index in results of the first complete item
318
319        if h_at_cursor >= cursor_end_should_lt {
320            start_index = self.cursor;
321            self.bottom += self.cursor;
322            self.cursor = 0;
323            self.cursor_above = 0;
324        } else
325        // increase the bottom index so that cursor_should_above is maintained
326        if let h_to_cursor_end = h_to_cursor + h_at_cursor
327            && h_to_cursor_end > cursor_end_should_lt
328        {
329            let mut trunc_height = h_to_cursor_end - cursor_end_should_lt;
330            // 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
331
332            for r in results[start_index as usize..self.cursor as usize].iter_mut() {
333                start_index += 1;
334                let h = height_of(r);
335                let (row, item) = r;
336
337                if trunc_height < h {
338                    let mut remaining_height = h - trunc_height;
339                    let prefix = if selector.contains(item) {
340                        self.config.multi_prefix.clone().to_string()
341                    } else {
342                        self.default_prefix(0)
343                    };
344
345                    total_height += remaining_height;
346
347                    if hz {
348                        if h - self._hr() < remaining_height {
349                            for (_, t) in
350                                row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0)
351                            {
352                                clip_text_lines(t, h - remaining_height, !self.reverse());
353                            }
354                        }
355
356                        prefix_text(&mut row[0], prefix);
357
358                        let last_visible = widths
359                            .iter()
360                            .enumerate()
361                            .rev()
362                            .find_map(|(i, w)| (*w != 0).then_some(i));
363
364                        let mut row_texts: Vec<_> = row
365                            .iter()
366                            .take(last_visible.map(|x| x + 1).unwrap_or(0))
367                            .cloned()
368                            .collect();
369
370                        if self.config.right_align_last && row_texts.len() > 1 {
371                            row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
372                        }
373
374                        let row = Row::new(row_texts).height(remaining_height);
375                        rows.push(row);
376                    } else {
377                        let mut push = vec![];
378
379                        for col in row.into_iter().rev() {
380                            let mut height = col.height() as u16;
381                            if remaining_height == 0 {
382                                break;
383                            } else if remaining_height < height {
384                                clip_text_lines(col, remaining_height, !self.reverse());
385                                height = remaining_height;
386                            }
387                            remaining_height -= height;
388                            prefix_text(col, prefix.clone());
389                            push.push(Row::new(vec![col.clone()]).height(height));
390                        }
391                        rows.extend(push);
392                    }
393
394                    self.bottom += start_index - 1;
395                    self.cursor -= start_index - 1;
396                    self.bottom_clip = Some(remaining_height);
397                    break;
398                } else if trunc_height == h {
399                    self.bottom += start_index;
400                    self.cursor -= start_index;
401                    self.bottom_clip = None;
402                    break;
403                }
404
405                trunc_height -= h;
406            }
407        } else if let Some(mut remaining_height) = self.bottom_clip {
408            start_index += 1;
409            // same as above
410            let h = height_of(&results[0]);
411            let (row, item) = &mut results[0];
412            let prefix = if selector.contains(item) {
413                self.config.multi_prefix.clone().to_string()
414            } else {
415                self.default_prefix(0)
416            };
417
418            total_height += remaining_height;
419
420            if hz {
421                if self._hr() + remaining_height != h {
422                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
423                        clip_text_lines(t, remaining_height, !self.reverse());
424                    }
425                }
426
427                prefix_text(&mut row[0], prefix);
428
429                let last_visible = widths
430                    .iter()
431                    .enumerate()
432                    .rev()
433                    .find_map(|(i, w)| (*w != 0).then_some(i));
434
435                let mut row_texts: Vec<_> = row
436                    .iter()
437                    .take(last_visible.map(|x| x + 1).unwrap_or(0))
438                    .cloned()
439                    .collect();
440
441                if self.config.right_align_last && row_texts.len() > 1 {
442                    row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
443                }
444
445                let row = Row::new(row_texts).height(remaining_height);
446                rows.push(row);
447            } else {
448                let mut push = vec![];
449
450                for col in row.into_iter().rev() {
451                    let mut height = col.height() as u16;
452                    if remaining_height == 0 {
453                        break;
454                    } else if remaining_height < height {
455                        clip_text_lines(col, remaining_height, !self.reverse());
456                        height = remaining_height;
457                    }
458                    remaining_height -= height;
459                    prefix_text(col, prefix.clone());
460                    push.push(Row::new(vec![col.clone()]).height(height));
461                }
462                rows.extend(push);
463            }
464        }
465
466        // topside padding is non-flexible, and does its best to stay at 2 full items without obscuring cursor.
467        // One option is we move enforcement from cursor_prev to
468
469        let mut remaining_height = self.height.saturating_sub(total_height);
470
471        for (mut i, (mut row, item)) in results.drain(start_index as usize..).enumerate() {
472            i += self.bottom_clip.is_some() as usize;
473
474            // this is technically one step out of sync but idc
475            if let Click::ResultPos(c) = click
476                && self.height - remaining_height > *c
477            {
478                let idx = self.bottom as u32 + i as u32 - 1;
479                log::debug!("Mapped click position to index: {c} -> {idx}",);
480                *click = Click::ResultIdx(idx);
481            }
482            if self.is_current(i) {
483                self.cursor_above = self.height - remaining_height;
484            }
485
486            // insert hr
487            if let Some(hr) = self.hr()
488                && remaining_height > 0
489            {
490                rows.push(hr);
491                remaining_height -= 1;
492            }
493            if remaining_height == 0 {
494                break;
495            }
496
497            // set prefix
498            let prefix = if selector.contains(item) {
499                self.config.multi_prefix.clone().to_string()
500            } else {
501                self.default_prefix(i)
502            };
503
504            if hz {
505                let mut height = row
506                    .iter()
507                    .map(|t| t.height() as u16)
508                    .max()
509                    .unwrap_or_default();
510
511                if remaining_height < height {
512                    height = remaining_height;
513
514                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
515                        clip_text_lines(t, height, self.reverse());
516                    }
517                }
518                remaining_height -= height;
519
520                prefix_text(&mut row[0], prefix);
521
522                // same as above
523                let last_visible = widths
524                    .iter()
525                    .enumerate()
526                    .rev()
527                    .find_map(|(i, w)| (*w != 0).then_some(i));
528
529                let mut row_texts: Vec<_> = row
530                    .iter()
531                    .take(last_visible.map(|x| x + 1).unwrap_or(0))
532                    .cloned()
533                    // highlight
534                    .enumerate()
535                    .map(|(x, t)| {
536                        if self.is_current(i)
537                            && (self.col.is_none()
538                                && matches!(
539                                    self.config.row_connection_style,
540                                    RowConnectionStyle::Disjoint
541                                )
542                                || self.col == Some(x))
543                        {
544                            t.style(self.current_style())
545                        } else {
546                            t
547                        }
548                    })
549                    .collect();
550
551                if self.config.right_align_last && row_texts.len() > 1 {
552                    row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
553                }
554
555                // push
556                let mut row = Row::new(row_texts).height(height);
557
558                if self.is_current(i)
559                    && self.col.is_none()
560                    && !matches!(
561                        self.config.row_connection_style,
562                        RowConnectionStyle::Disjoint
563                    )
564                {
565                    row = row.style(self.current_style())
566                }
567
568                rows.push(row);
569            } else {
570                let mut push = vec![];
571
572                for (x, mut col) in row.into_iter().enumerate() {
573                    let mut height = col.height() as u16;
574
575                    if remaining_height == 0 {
576                        break;
577                    } else if remaining_height < height {
578                        height = remaining_height;
579                        clip_text_lines(&mut col, remaining_height, !self.reverse());
580                    }
581                    remaining_height -= height;
582
583                    prefix_text(&mut col, prefix.clone());
584
585                    // push
586                    let mut row = Row::new(vec![col.clone()]).height(height);
587
588                    if self.is_current(i) && (self.col.is_none() || self.col == Some(x)) {
589                        row = row.style(self.current_style())
590                    }
591
592                    push.push(row);
593                }
594                rows.extend(push);
595            }
596        }
597
598        if self.reverse() {
599            rows.reverse();
600            if remaining_height > 0 {
601                rows.insert(0, Row::new(vec![vec![]]).height(remaining_height));
602            }
603        }
604
605        // up to the last nonempty row position
606
607        if hz {
608            self.widths = {
609                let pos = widths.iter().rposition(|&x| x != 0).map_or(0, |p| p + 1);
610                let mut widths = widths[..pos].to_vec();
611                if pos > 2 && self.config.right_align_last {
612                    let used = widths.iter().take(widths.len() - 1).sum();
613                    widths[pos - 1] = self.width().saturating_sub(used);
614                }
615                widths
616            };
617        }
618
619        // why does the row highlight apply beyond the table width?
620        let mut table = Table::new(
621            rows,
622            if hz {
623                self.widths.clone()
624            } else {
625                vec![self.width]
626            },
627        )
628        .column_spacing(self.config.column_spacing.0)
629        .style(self.config.fg)
630        .add_modifier(self.config.modifier);
631
632        table = table.block(self.config.border.as_static_block());
633        table
634    }
635}
636
637impl ResultsUI {
638    pub fn make_status(&self) -> Paragraph<'_> {
639        let substituted = substitute_escaped(
640            &self.status_config.template,
641            &[
642                ('r', &(self.index().to_string())),
643                ('m', &(self.status.matched_count.to_string())),
644                ('t', &(self.status.item_count.to_string())),
645            ],
646        )
647        .pad(
648            self.status_config.match_indent as usize * self.indentation(),
649            0,
650        );
651        Paragraph::new(substituted)
652            .style(self.status_config.fg)
653            .add_modifier(self.status_config.modifier)
654    }
655}
656
657// helpers
658impl ResultsUI {
659    fn default_prefix(&self, i: usize) -> String {
660        let substituted = substitute_escaped(
661            &self.config.default_prefix,
662            &[
663                ('d', &(i + 1).to_string()),                        // cursor index
664                ('r', &(i + 1 + self.bottom as usize).to_string()), // absolute index
665            ],
666        );
667
668        fit_width(&substituted, self.indentation())
669    }
670
671    fn current_style(&self) -> Style {
672        Style::from(self.config.current_fg)
673            .bg(self.config.current_bg)
674            .add_modifier(self.config.current_modifier)
675    }
676
677    fn is_current(&self, i: usize) -> bool {
678        !self.cursor_disabled && self.cursor == i as u16
679    }
680
681    pub fn match_style(&self) -> Style {
682        Style::default()
683            .fg(self.config.match_fg)
684            .add_modifier(self.config.match_modifier)
685    }
686
687    pub fn hr(&self) -> Option<Row<'static>> {
688        let sep = self.config.horizontal_separator;
689
690        if matches!(sep, HorizontalSeparator::None) {
691            return None;
692        }
693
694        // todo: support non_stacked by doing a seperate rendering pass
695        if !self.config.stacked_columns && self.widths.len() > 1 {
696            return Some(Row::new(vec![vec![]]));
697        }
698
699        let unit = sep.as_str();
700        let line = unit.repeat(self.width as usize);
701
702        Some(Row::new(vec![line]))
703    }
704
705    pub fn _hr(&self) -> u16 {
706        !matches!(self.config.horizontal_separator, HorizontalSeparator::None) as u16
707    }
708}