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 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 let mut lines = Vec::new();
236
237 let vertical_center = inner.height as usize / 2;
239 let horizontal_padding = (inner.width as usize - message_len) / 2 - 4;
240
241 for i in 0..inner.height {
243 if i as usize == vertical_center {
244 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 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 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_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 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 let cache_key = compute_cache_key(entry);
354 if let Some(rp) =
355 rendered_preview_cache.lock().unwrap().get(&cache_key)
356 {
357 let p = rp.paragraph.as_ref().clone();
359 f.render_widget(p.scroll((preview_scroll, 0)), inner);
360 return Ok(());
361 }
362 let rp = build_preview_paragraph(
364 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 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 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 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}