Skip to main content

matchmaker/ui/
results.rs

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