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::text::{clip_text_lines, fit_width, prefix_text, substitute_escaped},
17};
18
19#[derive(Debug)]
20pub struct ResultsUI {
21    cursor: u16,
22    bottom: u16,
23    height: u16, // actual height
24    width: u16,
25    // column widths.
26    // Note that the first width includes the indentation.
27    widths: Vec<u16>,
28    col: Option<usize>,
29    pub status: Status,
30    pub config: ResultsConfig,
31
32    pub cursor_disabled: bool,
33}
34
35impl ResultsUI {
36    pub fn new(config: ResultsConfig) -> Self {
37        Self {
38            cursor: 0,
39            bottom: 0,
40            col: None,
41            widths: Vec::new(),
42            status: Default::default(),
43            height: 0, // uninitialized, so be sure to call update_dimensions
44            width: 0,
45            config,
46            cursor_disabled: false,
47        }
48    }
49    // as given by ratatui area
50    pub fn update_dimensions(&mut self, area: &Rect) {
51        let [bw, bh] = [self.config.border.height(), self.config.border.width()];
52        self.width = area.width.saturating_sub(bw);
53        self.height = area.height.saturating_sub(bh);
54    }
55
56    pub fn table_width(&self) -> u16 {
57        self.config.column_spacing.0 * self.widths().len().saturating_sub(1) as u16
58            + self.widths.iter().sum::<u16>()
59            + self.config.border.width()
60    }
61
62    // ------ config -------
63    pub fn reverse(&self) -> bool {
64        self.config.reverse.is_always()
65    }
66    pub fn is_wrap(&self) -> bool {
67        self.config.wrap
68    }
69    pub fn wrap(&mut self, wrap: bool) {
70        self.config.wrap = wrap;
71    }
72
73    // ----- columns --------
74    // todo: support cooler things like only showing/outputting a specific column/cycling columns
75    pub fn toggle_col(&mut self, col_idx: usize) -> bool {
76        if self.col == Some(col_idx) {
77            self.col = None
78        } else {
79            self.col = Some(col_idx);
80        }
81        self.col.is_some()
82    }
83    pub fn cycle_col(&mut self) {
84        self.col = match self.col {
85            None => self.widths.is_empty().then_some(0),
86            Some(c) => {
87                let next = c + 1;
88                if next < self.widths.len() {
89                    Some(next)
90                } else {
91                    None
92                }
93            }
94        };
95    }
96
97    // ------- NAVIGATION ---------
98    fn scroll_padding(&self) -> u16 {
99        self.config.scroll_padding.min(self.height / 2)
100    }
101    pub fn end(&self) -> u32 {
102        self.status.matched_count.saturating_sub(1)
103    }
104
105    /// Index in worker snapshot of current item.
106    /// Use with worker.get_nth().
107    //  Equivalently, the cursor progress in the match list
108    pub fn index(&self) -> u32 {
109        if self.cursor_disabled {
110            u32::MAX
111        } else {
112            (self.cursor + self.bottom) as u32
113        }
114    }
115    // pub fn cursor(&self) -> Option<u16> {
116    //     if self.cursor_disabled {
117    //         None
118    //     } else {
119    //         Some(self.cursor)
120    //     }
121    // }
122    pub fn cursor_prev(&mut self) {
123        if self.cursor <= self.scroll_padding() && self.bottom > 0 {
124            self.bottom -= 1;
125        } else if self.cursor > 0 {
126            self.cursor -= 1;
127        } else if self.config.scroll_wrap {
128            self.cursor_jump(self.end());
129        }
130    }
131    pub fn cursor_next(&mut self) {
132        if self.cursor_disabled {
133            self.cursor_disabled = false
134        }
135
136        log::trace!(
137            "Cursor {} @ index {}. Status: {:?}.",
138            self.cursor,
139            self.index(),
140            self.status
141        );
142
143        if self.cursor + 1 + self.scroll_padding() >= self.height
144            && self.bottom + self.height < self.status.matched_count as u16
145        {
146            self.bottom += 1;
147        } else if self.index() < self.end() {
148            self.cursor += 1;
149        } else if self.config.scroll_wrap {
150            self.cursor_jump(0)
151        }
152    }
153
154    pub fn cursor_jump(&mut self, index: u32) {
155        self.cursor_disabled = false;
156
157        let end = self.end();
158        let index = index.min(end) as u16;
159
160        if index < self.bottom || index >= self.bottom + self.height {
161            self.bottom = (end as u16 + 1).saturating_sub(self.height).min(index);
162            self.cursor = index - self.bottom;
163        } else {
164            self.cursor = index - self.bottom;
165        }
166    }
167
168    // ------- RENDERING ----------
169    pub fn indentation(&self) -> usize {
170        self.config.multi_prefix.width()
171    }
172    pub fn col(&self) -> Option<usize> {
173        self.col
174    }
175
176    /// Column widths.
177    /// Note that the first width includes the indentation.
178    pub fn widths(&self) -> &Vec<u16> {
179        &self.widths
180    }
181    // results width
182    pub fn width(&self) -> u16 {
183        self.width.saturating_sub(self.indentation() as u16)
184    }
185    pub fn match_style(&self) -> Style {
186        Style::default()
187            .fg(self.config.match_fg)
188            .add_modifier(self.config.match_modifier)
189    }
190
191    pub fn max_widths(&self) -> Vec<u16> {
192        if !self.config.wrap {
193            return vec![];
194        }
195
196        let mut widths = vec![u16::MAX; self.widths.len()];
197
198        let total: u16 = self.widths.iter().sum();
199        if total <= self.width() {
200            return vec![];
201        }
202
203        let mut available = self.width();
204        let mut scale_total = 0;
205        let mut scalable_indices = Vec::new();
206
207        for (i, &w) in self.widths.iter().enumerate() {
208            if w <= self.config.wrap_scaling_min_width {
209                available = available.saturating_sub(w);
210            } else {
211                scale_total += w;
212                scalable_indices.push(i);
213            }
214        }
215
216        for &i in &scalable_indices {
217            let old = self.widths[i];
218            let new_w = old * available / scale_total;
219            widths[i] = new_w.max(self.config.wrap_scaling_min_width);
220        }
221
222        // give remainder to the last scalable column
223        if let Some(&last_idx) = scalable_indices.last() {
224            let used_total: u16 = widths.iter().sum();
225            if used_total < self.width() {
226                widths[last_idx] += self.width() - used_total;
227            }
228        }
229
230        widths
231    }
232
233    // this updates the internal status, so be sure to call make_status afterward
234    // some janky wrapping is implemented, dunno whats causing flickering, padding is fixed going down only
235    pub fn make_table<'a, T: SSS>(
236        &mut self,
237        worker: &'a mut Worker<T>,
238        selector: &mut Selector<T, impl Selection>,
239        matcher: &mut nucleo::Matcher,
240        click: &mut Click,
241    ) -> Table<'a> {
242        let offset = self.bottom as u32;
243        let end = (self.bottom + self.height) as u32;
244
245        let (mut results, mut widths, status) =
246            worker.results(offset, end, &self.max_widths(), self.match_style(), matcher);
247
248        let match_count = status.matched_count;
249        self.status = status;
250
251        if match_count < (self.bottom + self.cursor) as u32 && !self.cursor_disabled {
252            self.cursor_jump(match_count);
253        } else {
254            self.cursor = self.cursor.min(results.len().saturating_sub(1) as u16)
255        }
256
257        widths[0] += self.indentation() as u16;
258
259        let mut rows = vec![];
260        let mut total_height = 0;
261
262        if results.is_empty() {
263            return Table::new(rows, widths);
264        }
265
266        // debug!("sb: {}, {}, {}, {}, {}", self.bottom, self.cursor, total_height, self.height, results.len());
267        let cursor_result_h = results[self.cursor as usize].2;
268        // the index in results of the first complete item
269        let mut start_index = 0;
270
271        let cum_h_after_cursor = results[(self.cursor as usize + 1).min(results.len())..]
272            .iter()
273            .map(|(_, _, height)| height)
274            .sum::<u16>();
275
276        let cursor_should_lt = self.height - self.scroll_padding().min(cum_h_after_cursor);
277
278        if cursor_result_h >= cursor_should_lt {
279            start_index = self.cursor;
280            self.bottom += self.cursor;
281            self.cursor = 0;
282        } else
283        // increase the bottom index so that cursor_should_above is maintained
284        if let cum_h_to_cursor = results[0..=self.cursor as usize]
285            .iter()
286            .map(|(_, _, height)| height)
287            .sum::<u16>()
288            && cum_h_to_cursor > cursor_should_lt
289        {
290            start_index = 1;
291            let mut remaining_height = cum_h_to_cursor.saturating_sub(cursor_should_lt);
292
293            for (row, item, h) in results[..self.cursor as usize].iter_mut() {
294                let h = *h; // item height
295
296                if remaining_height < h {
297                    for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
298                        clip_text_lines(t, remaining_height, !self.reverse());
299                    }
300                    total_height = remaining_height;
301
302                    let prefix = if selector.contains(item) {
303                        self.config.multi_prefix.clone().to_string()
304                    } else {
305                        fit_width(
306                            &substitute_escaped(
307                                &self.config.default_prefix,
308                                &[
309                                    ('d', ""), // no indices
310                                    ('r', ""),
311                                ],
312                            ),
313                            self.indentation(),
314                        )
315                    };
316
317                    prefix_text(&mut row[0], prefix);
318
319                    let last_visible = widths
320                        .iter()
321                        .enumerate()
322                        .rev()
323                        .find_map(|(i, w)| (*w != 0).then_some(i));
324
325                    let mut row_texts: Vec<_> = row
326                        .iter()
327                        .take(last_visible.map(|x| x + 1).unwrap_or(0))
328                        .cloned()
329                        .collect();
330                    if self.config.right_align_last && row_texts.len() > 1 {
331                        row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
332                    }
333
334                    let row = Row::new(row_texts).height(remaining_height);
335                    rows.push(row);
336
337                    self.bottom += start_index - 1;
338                    self.cursor -= start_index - 1;
339                    break;
340                } else if remaining_height == h {
341                    self.bottom += start_index;
342                    self.cursor -= start_index;
343                    // debug!("2: {} {}", start_index, h);
344                    break;
345                }
346
347                start_index += 1;
348                remaining_height -= h;
349            }
350        }
351
352        // debug!("si: {start_index}, {}, {}, {}", self.bottom, self.cursor, total_height);
353
354        for (i, (mut row, item, mut height)) in
355            (start_index..).zip(results.drain(start_index as usize..))
356        {
357            if let Click::ResultPos(c) = click
358                && total_height > *c
359            {
360                let idx = offset + i as u32 - 1;
361                log::debug!("Mapped click position to index: {c} -> {idx}",);
362                *click = Click::ResultIdx(idx);
363            }
364
365            if self.height - total_height == 0 {
366                break;
367            } else if self.height - total_height < height {
368                height = self.height - total_height;
369
370                for (_, t) in row.iter_mut().enumerate().filter(|(i, _)| widths[*i] != 0) {
371                    clip_text_lines(t, height, self.reverse());
372                }
373                total_height = self.height;
374            } else {
375                total_height += height;
376            }
377
378            let prefix = if selector.contains(item) {
379                self.config.multi_prefix.clone().to_string()
380            } else {
381                fit_width(
382                    &substitute_escaped(
383                        &self.config.default_prefix,
384                        &[
385                            ('d', &(i + 1).to_string()),               // cursor index of item
386                            ('r', &(i + 1 + self.bottom).to_string()), // actual index
387                        ],
388                    ),
389                    self.indentation(),
390                )
391            };
392
393            prefix_text(&mut row[0], prefix);
394
395            if !self.cursor_disabled && i == self.cursor {
396                row = row
397                    .into_iter()
398                    .enumerate()
399                    .map(|(i, t)| {
400                        if self.col == Some(i)
401                            || (self.col.is_none()
402                                && matches!(
403                                    self.config.row_connection_style,
404                                    RowConnectionStyle::Disjoint
405                                ))
406                        {
407                            t.style(self.config.current_fg)
408                                .bg(self.config.current_bg)
409                                .add_modifier(self.config.current_modifier)
410                        } else {
411                            t
412                        }
413                    })
414                    .collect();
415            }
416
417            // same as above
418            let last_visible = widths
419                .iter()
420                .enumerate()
421                .rev()
422                .find_map(|(i, w)| (*w != 0).then_some(i));
423
424            let mut row_texts: Vec<_> = row
425                .iter()
426                .take(last_visible.map(|x| x + 1).unwrap_or(0))
427                .cloned()
428                .collect();
429            if self.config.right_align_last && row_texts.len() > 1 {
430                row_texts.last_mut().unwrap().alignment = Some(Alignment::Right)
431            }
432
433            let mut row = Row::new(row_texts).height(height);
434
435            if i == self.cursor
436                && self.col.is_none()
437                && !matches!(
438                    self.config.row_connection_style,
439                    RowConnectionStyle::Disjoint
440                )
441            {
442                row = row
443                    .style(self.config.current_fg)
444                    .bg(self.config.current_bg)
445                    .add_modifier(self.config.current_modifier)
446            }
447
448            rows.push(row);
449        }
450
451        if self.reverse() {
452            rows.reverse();
453            if total_height < self.height {
454                let spacer_height = self.height - total_height;
455                rows.insert(0, Row::new(vec![vec![]]).height(spacer_height));
456            }
457        }
458
459        // up to the last nonempty row position
460        self.widths = {
461            let pos = widths.iter().rposition(|&x| x != 0).map_or(0, |p| p + 1);
462            let mut widths = widths[..pos].to_vec();
463            if pos > 2 && self.config.right_align_last {
464                let used = widths.iter().take(widths.len() - 1).sum();
465                widths[pos - 1] = self.width().saturating_sub(used);
466            }
467            widths
468        };
469
470        // why does the row highlight apply beyond the table width?
471        let mut table = Table::new(rows, self.widths.clone())
472            .column_spacing(self.config.column_spacing.0)
473            .style(self.config.fg)
474            .add_modifier(self.config.modifier);
475
476        table = table.block(self.config.border.as_static_block());
477        table
478    }
479
480    pub fn make_status(&self) -> Paragraph<'_> {
481        Paragraph::new(format!(
482            "{}{}/{}",
483            " ".repeat(self.indentation()),
484            &self.status.matched_count,
485            &self.status.item_count
486        ))
487        .style(self.config.status_fg)
488        .add_modifier(self.config.status_modifier)
489    }
490}