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