television_screen/
preview.rs

1use crate::{
2    cache::RenderedPreviewCache,
3    colors::{Colorscheme, PreviewColorscheme},
4};
5use color_eyre::eyre::Result;
6use devicons::FileIcon;
7use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap};
8use ratatui::Frame;
9use ratatui::{
10    layout::{Alignment, Rect},
11    prelude::{Color, Line, Modifier, Span, Style, Stylize, Text},
12};
13use std::str::FromStr;
14use std::sync::{Arc, Mutex};
15use television_channels::entry::Entry;
16use television_previewers::{
17    ansi::IntoText,
18    previewers::{
19        Preview, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG,
20        PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG,
21    },
22};
23use television_utils::strings::{
24    replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
25    EMPTY_STRING,
26};
27
28#[allow(dead_code)]
29const FILL_CHAR_SLANTED: char = '╱';
30const FILL_CHAR_EMPTY: char = ' ';
31
32#[allow(clippy::needless_pass_by_value)]
33pub fn build_preview_paragraph<'a>(
34    inner: Rect,
35    preview_content: PreviewContent,
36    target_line: Option<u16>,
37    preview_scroll: u16,
38    colorscheme: Colorscheme,
39) -> Paragraph<'a> {
40    let preview_block =
41        Block::default().style(Style::default()).padding(Padding {
42            top: 0,
43            right: 1,
44            bottom: 0,
45            left: 1,
46        });
47    match preview_content {
48        PreviewContent::AnsiText(text) => {
49            build_ansi_text_paragraph(text, preview_block, preview_scroll)
50        }
51        PreviewContent::PlainText(content) => build_plain_text_paragraph(
52            content,
53            preview_block,
54            target_line,
55            preview_scroll,
56            colorscheme.preview,
57        ),
58        PreviewContent::PlainTextWrapped(content) => {
59            build_plain_text_wrapped_paragraph(
60                content,
61                preview_block,
62                colorscheme.preview,
63            )
64        }
65        PreviewContent::SyntectHighlightedText(highlighted_lines) => {
66            build_syntect_highlighted_paragraph(
67                highlighted_lines.lines,
68                preview_block,
69                target_line,
70                preview_scroll,
71                colorscheme.preview,
72            )
73        }
74        // meta
75        PreviewContent::Loading => {
76            build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY)
77                .block(preview_block)
78                .alignment(Alignment::Left)
79                .style(Style::default().add_modifier(Modifier::ITALIC))
80        }
81        PreviewContent::NotSupported => build_meta_preview_paragraph(
82            inner,
83            PREVIEW_NOT_SUPPORTED_MSG,
84            FILL_CHAR_EMPTY,
85        )
86        .block(preview_block)
87        .alignment(Alignment::Left)
88        .style(Style::default().add_modifier(Modifier::ITALIC)),
89        PreviewContent::FileTooLarge => build_meta_preview_paragraph(
90            inner,
91            FILE_TOO_LARGE_MSG,
92            FILL_CHAR_EMPTY,
93        )
94        .block(preview_block)
95        .alignment(Alignment::Left)
96        .style(Style::default().add_modifier(Modifier::ITALIC)),
97        PreviewContent::Timeout => {
98            build_meta_preview_paragraph(inner, TIMEOUT_MSG, FILL_CHAR_EMPTY)
99        }
100        .block(preview_block)
101        .alignment(Alignment::Left)
102        .style(Style::default().add_modifier(Modifier::ITALIC)),
103        PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)),
104    }
105}
106
107const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10;
108const ANSI_CONTEXT_SIZE: usize = 150;
109
110#[allow(clippy::needless_pass_by_value)]
111fn build_ansi_text_paragraph(
112    text: String,
113    preview_block: Block,
114    preview_scroll: u16,
115) -> Paragraph {
116    let lines = text.lines();
117    let skip =
118        preview_scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE) as usize;
119    let context = lines
120        .skip(skip)
121        .take(ANSI_CONTEXT_SIZE)
122        .collect::<Vec<_>>()
123        .join("\n");
124
125    let mut text = "\n".repeat(skip);
126    text.push_str(
127        &replace_non_printable(
128            context.as_bytes(),
129            &ReplaceNonPrintableConfig {
130                replace_line_feed: false,
131                replace_control_characters: false,
132                ..Default::default()
133            },
134        )
135        .0,
136    );
137
138    Paragraph::new(text.into_text().unwrap())
139        .block(preview_block)
140        .scroll((preview_scroll, 0))
141}
142
143#[allow(clippy::needless_pass_by_value)]
144fn build_plain_text_paragraph(
145    text: Vec<String>,
146    preview_block: Block<'_>,
147    target_line: Option<u16>,
148    preview_scroll: u16,
149    colorscheme: PreviewColorscheme,
150) -> Paragraph<'_> {
151    let mut lines = Vec::new();
152    for (i, line) in text.iter().enumerate() {
153        lines.push(Line::from(vec![
154            build_line_number_span(i + 1).style(Style::default().fg(
155                if matches!(
156                        target_line,
157                        Some(l) if l == u16::try_from(i).unwrap_or(0) + 1
158                    )
159                {
160                    colorscheme.gutter_selected_fg
161                } else {
162                    colorscheme.gutter_fg
163                },
164            )),
165            Span::styled(" │ ",
166                         Style::default().fg(colorscheme.gutter_fg).dim()),
167            Span::styled(
168                line.to_string(),
169                Style::default().fg(colorscheme.content_fg).bg(
170                    if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) {
171                        colorscheme.highlight_bg
172                    } else {
173                        Color::Reset
174                    },
175                ),
176            ),
177        ]));
178    }
179    let text = Text::from(lines);
180    Paragraph::new(text)
181        .block(preview_block)
182        .scroll((preview_scroll, 0))
183}
184
185#[allow(clippy::needless_pass_by_value)]
186fn build_plain_text_wrapped_paragraph(
187    text: String,
188    preview_block: Block<'_>,
189    colorscheme: PreviewColorscheme,
190) -> Paragraph<'_> {
191    let mut lines = Vec::new();
192    for line in text.lines() {
193        lines.push(Line::styled(
194            line.to_string(),
195            Style::default().fg(colorscheme.content_fg),
196        ));
197    }
198    let text = Text::from(lines);
199    Paragraph::new(text)
200        .block(preview_block)
201        .wrap(Wrap { trim: true })
202}
203
204#[allow(clippy::needless_pass_by_value)]
205fn build_syntect_highlighted_paragraph(
206    highlighted_lines: Vec<Vec<(syntect::highlighting::Style, String)>>,
207    preview_block: Block,
208    target_line: Option<u16>,
209    preview_scroll: u16,
210    colorscheme: PreviewColorscheme,
211) -> Paragraph {
212    compute_paragraph_from_highlighted_lines(
213        &highlighted_lines,
214        target_line.map(|l| l as usize),
215        colorscheme,
216    )
217    .block(preview_block)
218    .alignment(Alignment::Left)
219    .scroll((preview_scroll, 0))
220}
221
222pub fn build_meta_preview_paragraph<'a>(
223    inner: Rect,
224    message: &str,
225    fill_char: char,
226) -> Paragraph<'a> {
227    let message_len = message.len();
228    if message_len + 8 > inner.width as usize {
229        return Paragraph::new(Text::from(EMPTY_STRING));
230    }
231    let fill_char_str = fill_char.to_string();
232    let fill_line = fill_char_str.repeat(inner.width as usize);
233
234    // Build the paragraph content with slanted lines and center the custom message
235    let mut lines = Vec::new();
236
237    // Calculate the vertical center
238    let vertical_center = inner.height as usize / 2;
239    let horizontal_padding = (inner.width as usize - message_len) / 2 - 4;
240
241    // Fill the paragraph with slanted lines and insert the centered custom message
242    for i in 0..inner.height {
243        if i as usize == vertical_center {
244            // Center the message horizontally in the middle line
245            let line = format!(
246                "{}  {}  {}",
247                fill_char_str.repeat(horizontal_padding),
248                message,
249                fill_char_str.repeat(
250                    inner.width as usize - horizontal_padding - message_len
251                )
252            );
253            lines.push(Line::from(line));
254        } else if i as usize + 1 == vertical_center
255            || (i as usize).saturating_sub(1) == vertical_center
256        {
257            let line = format!(
258                "{}  {}  {}",
259                fill_char_str.repeat(horizontal_padding),
260                " ".repeat(message_len),
261                fill_char_str.repeat(
262                    inner.width as usize - horizontal_padding - message_len
263                )
264            );
265            lines.push(Line::from(line));
266        } else {
267            lines.push(Line::from(fill_line.clone()));
268        }
269    }
270
271    // Create a paragraph with the generated content
272    Paragraph::new(Text::from(lines))
273}
274
275fn draw_content_outer_block(
276    f: &mut Frame,
277    rect: Rect,
278    colorscheme: &Colorscheme,
279    icon: Option<FileIcon>,
280    title: &str,
281    use_nerd_font_icons: bool,
282) -> Result<Rect> {
283    let mut preview_title_spans = vec![Span::from(" ")];
284    // optional icon
285    if icon.is_some() && use_nerd_font_icons {
286        let icon = icon.as_ref().unwrap();
287        preview_title_spans.push(Span::styled(
288            {
289                let mut icon_str = String::from(icon.icon);
290                icon_str.push(' ');
291                icon_str
292            },
293            Style::default().fg(Color::from_str(icon.color)?),
294        ));
295    }
296    // preview title
297    preview_title_spans.push(Span::styled(
298        shrink_with_ellipsis(
299            &replace_non_printable(
300                title.as_bytes(),
301                &ReplaceNonPrintableConfig::default(),
302            )
303            .0,
304            rect.width.saturating_sub(4) as usize,
305        ),
306        Style::default().fg(colorscheme.preview.title_fg).bold(),
307    ));
308    preview_title_spans.push(Span::from(" "));
309
310    // build the preview block
311    let preview_outer_block = Block::default()
312        .title_top(
313            Line::from(preview_title_spans)
314                .alignment(Alignment::Center)
315                .style(Style::default().fg(colorscheme.preview.title_fg)),
316        )
317        .borders(Borders::ALL)
318        .border_type(BorderType::Rounded)
319        .border_style(Style::default().fg(colorscheme.general.border_fg))
320        .style(
321            Style::default()
322                .bg(colorscheme.general.background.unwrap_or_default()),
323        )
324        .padding(Padding::new(0, 1, 1, 0));
325
326    let inner = preview_outer_block.inner(rect);
327    f.render_widget(preview_outer_block, rect);
328    Ok(inner)
329}
330
331#[allow(clippy::too_many_arguments)]
332pub fn draw_preview_content_block(
333    f: &mut Frame,
334    rect: Rect,
335    entry: &Entry,
336    preview: &Option<Arc<Preview>>,
337    rendered_preview_cache: &Arc<Mutex<RenderedPreviewCache<'static>>>,
338    preview_scroll: u16,
339    use_nerd_font_icons: bool,
340    colorscheme: &Colorscheme,
341) -> Result<()> {
342    if let Some(preview) = preview {
343        let inner = draw_content_outer_block(
344            f,
345            rect,
346            colorscheme,
347            preview.icon,
348            &preview.title,
349            use_nerd_font_icons,
350        )?;
351
352        // check if the rendered preview content is already in the cache
353        let cache_key = compute_cache_key(entry);
354        if let Some(rp) =
355            rendered_preview_cache.lock().unwrap().get(&cache_key)
356        {
357            // we got a hit, render the cached preview content
358            let p = rp.paragraph.as_ref().clone();
359            f.render_widget(p.scroll((preview_scroll, 0)), inner);
360            return Ok(());
361        }
362        // render the preview content and cache it
363        let rp = build_preview_paragraph(
364            //preview_inner_block,
365            inner,
366            preview.content.clone(),
367            entry.line_number.map(|l| u16::try_from(l).unwrap_or(0)),
368            preview_scroll,
369            colorscheme.clone(),
370        );
371        // only cache the preview content if it's not a partial preview
372        // and the preview title matches the entry name
373        if preview.partial_offset.is_none() && preview.title == entry.name {
374            rendered_preview_cache.lock().unwrap().insert(
375                cache_key,
376                preview.icon,
377                &preview.title,
378                &Arc::new(rp.clone()),
379            );
380        }
381        f.render_widget(rp.scroll((preview_scroll, 0)), inner);
382        return Ok(());
383    }
384    // else if last_preview exists
385    if let Some(last_preview) =
386        &rendered_preview_cache.lock().unwrap().last_preview
387    {
388        let inner = draw_content_outer_block(
389            f,
390            rect,
391            colorscheme,
392            last_preview.icon,
393            &last_preview.title,
394            use_nerd_font_icons,
395        )?;
396
397        f.render_widget(
398            last_preview
399                .paragraph
400                .as_ref()
401                .clone()
402                .scroll((preview_scroll, 0)),
403            inner,
404        );
405        return Ok(());
406    }
407    // otherwise render empty preview
408    let inner = draw_content_outer_block(
409        f,
410        rect,
411        colorscheme,
412        None,
413        "",
414        use_nerd_font_icons,
415    )?;
416    let preview_outer_block = Block::default()
417        .title_top(Line::from(Span::styled(
418            " Preview ",
419            Style::default().fg(colorscheme.preview.title_fg),
420        )))
421        .borders(Borders::ALL)
422        .border_type(BorderType::Rounded)
423        .border_style(Style::default().fg(colorscheme.general.border_fg))
424        .style(
425            Style::default()
426                .bg(colorscheme.general.background.unwrap_or_default()),
427        )
428        .padding(Padding::new(0, 1, 1, 0));
429    f.render_widget(preview_outer_block, inner);
430
431    Ok(())
432}
433
434fn build_line_number_span<'a>(line_number: usize) -> Span<'a> {
435    Span::from(format!("{line_number:5} "))
436}
437
438fn compute_paragraph_from_highlighted_lines(
439    highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>],
440    line_specifier: Option<usize>,
441    colorscheme: PreviewColorscheme,
442) -> Paragraph<'static> {
443    let preview_lines: Vec<Line> = highlighted_lines
444        .iter()
445        .enumerate()
446        .map(|(i, l)| {
447            let line_number =
448                build_line_number_span(i + 1).style(Style::default().fg(
449                    if line_specifier.is_some()
450                        && i == line_specifier.unwrap().saturating_sub(1)
451                    {
452                        colorscheme.gutter_selected_fg
453                    } else {
454                        colorscheme.gutter_fg
455                    },
456                ));
457            Line::from_iter(
458                std::iter::once(line_number)
459                    .chain(std::iter::once(Span::styled(
460                        " │ ",
461                        Style::default().fg(colorscheme.gutter_fg).dim(),
462                    )))
463                    .chain(l.iter().cloned().map(|sr| {
464                        convert_syn_region_to_span(
465                            &(sr.0, sr.1),
466                            if line_specifier.is_some()
467                                && i == line_specifier
468                                    .unwrap()
469                                    .saturating_sub(1)
470                            {
471                                Some(colorscheme.highlight_bg)
472                            } else {
473                                None
474                            },
475                        )
476                    })),
477            )
478        })
479        .collect();
480
481    Paragraph::new(preview_lines)
482}
483
484pub fn convert_syn_region_to_span<'a>(
485    syn_region: &(syntect::highlighting::Style, String),
486    background: Option<Color>,
487) -> Span<'a> {
488    let mut style = Style::default()
489        .fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground));
490    if let Some(background) = background {
491        style = style.bg(background);
492    }
493    style = match syn_region.0.font_style {
494        syntect::highlighting::FontStyle::BOLD => style.bold(),
495        syntect::highlighting::FontStyle::ITALIC => style.italic(),
496        syntect::highlighting::FontStyle::UNDERLINE => style.underlined(),
497        _ => style,
498    };
499    Span::styled(syn_region.1.clone(), style)
500}
501
502fn convert_syn_color_to_ratatui_color(
503    color: syntect::highlighting::Color,
504) -> Color {
505    Color::Rgb(color.r, color.g, color.b)
506}
507
508fn compute_cache_key(entry: &Entry) -> String {
509    let mut cache_key = entry.name.clone();
510    if let Some(line_number) = entry.line_number {
511        cache_key.push_str(&line_number.to_string());
512    }
513    cache_key
514}