television_screen/
results.rs

1use crate::colors::{Colorscheme, ResultsColorscheme};
2use crate::layout::InputPosition;
3use color_eyre::eyre::Result;
4use ratatui::layout::{Alignment, Rect};
5use ratatui::prelude::{Color, Line, Span, Style};
6use ratatui::style::Stylize;
7use ratatui::widgets::{
8    Block, BorderType, Borders, List, ListDirection, ListState, Padding,
9};
10use ratatui::Frame;
11use rustc_hash::{FxHashMap, FxHashSet};
12use std::str::FromStr;
13use television_channels::entry::Entry;
14use television_utils::strings::{
15    make_matched_string_printable, next_char_boundary,
16    slice_at_char_boundaries,
17};
18
19const POINTER_SYMBOL: &str = "> ";
20const SELECTED_SYMBOL: &str = "● ";
21const DESLECTED_SYMBOL: &str = "  ";
22
23pub fn build_results_list<'a, 'b>(
24    results_block: Block<'b>,
25    entries: &'a [Entry],
26    selected_entries: Option<&FxHashSet<Entry>>,
27    list_direction: ListDirection,
28    use_icons: bool,
29    icon_color_cache: &mut FxHashMap<String, Color>,
30    colorscheme: &ResultsColorscheme,
31) -> List<'a>
32where
33    'b: 'a,
34{
35    List::new(entries.iter().map(|entry| {
36        let mut spans = Vec::new();
37        // optional selection symbol
38        if let Some(selected_entries) = selected_entries {
39            if !selected_entries.is_empty() {
40                spans.push(if selected_entries.contains(entry) {
41                    Span::styled(
42                        SELECTED_SYMBOL,
43                        Style::default().fg(colorscheme.result_selected_fg),
44                    )
45                } else {
46                    Span::from(DESLECTED_SYMBOL)
47                });
48            }
49        }
50        // optional icon
51        if let Some(icon) = entry.icon.as_ref() {
52            if use_icons {
53                if let Some(icon_color) = icon_color_cache.get(icon.color) {
54                    spans.push(Span::styled(
55                        icon.to_string(),
56                        Style::default().fg(*icon_color),
57                    ));
58                } else {
59                    let icon_color = Color::from_str(icon.color).unwrap();
60                    icon_color_cache
61                        .insert(icon.color.to_string(), icon_color);
62                    spans.push(Span::styled(
63                        icon.to_string(),
64                        Style::default().fg(icon_color),
65                    ));
66                }
67
68                spans.push(Span::raw(" "));
69            }
70        }
71        // entry name
72        let (entry_name, name_match_ranges) = make_matched_string_printable(
73            &entry.name,
74            entry.name_match_ranges.as_deref(),
75        );
76        let mut last_match_end = 0;
77        for (start, end) in name_match_ranges
78            .iter()
79            .map(|(s, e)| (*s as usize, *e as usize))
80        {
81            // from the end of the last match to the start of the current one
82            spans.push(Span::styled(
83                slice_at_char_boundaries(&entry_name, last_match_end, start)
84                    .to_string(),
85                Style::default().fg(colorscheme.result_name_fg),
86            ));
87            // the current match
88            spans.push(Span::styled(
89                slice_at_char_boundaries(&entry_name, start, end).to_string(),
90                Style::default().fg(colorscheme.match_foreground_color),
91            ));
92            last_match_end = end;
93        }
94        // we need to push a span for the remainder of the entry name
95        // but only if there's something left
96        let next_boundary = next_char_boundary(&entry_name, last_match_end);
97        if next_boundary < entry_name.len() {
98            let remainder = entry_name[next_boundary..].to_string();
99            spans.push(Span::styled(
100                remainder,
101                Style::default().fg(colorscheme.result_name_fg),
102            ));
103        }
104        // optional line number
105        if let Some(line_number) = entry.line_number {
106            spans.push(Span::styled(
107                format!(":{line_number}"),
108                Style::default().fg(colorscheme.result_line_number_fg),
109            ));
110        }
111        // optional preview
112        if let Some(preview) = &entry.value {
113            spans.push(Span::raw(": "));
114
115            let (preview, preview_match_ranges) =
116                make_matched_string_printable(
117                    preview,
118                    entry.value_match_ranges.as_deref(),
119                );
120            let mut last_match_end = 0;
121            for (start, end) in preview_match_ranges
122                .iter()
123                .map(|(s, e)| (*s as usize, *e as usize))
124            {
125                spans.push(Span::styled(
126                    slice_at_char_boundaries(&preview, last_match_end, start)
127                        .to_string(),
128                    Style::default().fg(colorscheme.result_preview_fg),
129                ));
130                spans.push(Span::styled(
131                    slice_at_char_boundaries(&preview, start, end).to_string(),
132                    Style::default().fg(colorscheme.match_foreground_color),
133                ));
134                last_match_end = end;
135            }
136            let next_boundary = next_char_boundary(&preview, last_match_end);
137            if next_boundary < preview.len() {
138                spans.push(Span::styled(
139                    preview[next_boundary..].to_string(),
140                    Style::default().fg(colorscheme.result_preview_fg),
141                ));
142            }
143        }
144        Line::from(spans)
145    }))
146    .direction(list_direction)
147    .highlight_style(
148        Style::default().bg(colorscheme.result_selected_bg).bold(),
149    )
150    .highlight_symbol(POINTER_SYMBOL)
151    .block(results_block)
152}
153
154#[allow(clippy::too_many_arguments)]
155pub fn draw_results_list(
156    f: &mut Frame,
157    rect: Rect,
158    entries: &[Entry],
159    selected_entries: &FxHashSet<Entry>,
160    relative_picker_state: &mut ListState,
161    input_bar_position: InputPosition,
162    use_nerd_font_icons: bool,
163    icon_color_cache: &mut FxHashMap<String, Color>,
164    colorscheme: &Colorscheme,
165    help_keybinding: &str,
166    preview_keybinding: &str,
167) -> Result<()> {
168    let results_block = Block::default()
169        .title_top(Line::from(" Results ").alignment(Alignment::Center))
170        .title_bottom(
171            Line::from(format!(
172                " help: <{help_keybinding}>  preview: <{preview_keybinding}> "
173            ))
174            .alignment(Alignment::Center),
175        )
176        .borders(Borders::ALL)
177        .border_type(BorderType::Rounded)
178        .border_style(Style::default().fg(colorscheme.general.border_fg))
179        .style(
180            Style::default()
181                .bg(colorscheme.general.background.unwrap_or_default()),
182        )
183        .padding(Padding::right(1));
184
185    let results_list = build_results_list(
186        results_block,
187        entries,
188        Some(selected_entries),
189        match input_bar_position {
190            InputPosition::Bottom => ListDirection::BottomToTop,
191            InputPosition::Top => ListDirection::TopToBottom,
192        },
193        use_nerd_font_icons,
194        icon_color_cache,
195        &colorscheme.results,
196    );
197
198    f.render_stateful_widget(results_list, rect, relative_picker_state);
199    Ok(())
200}