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