Skip to main content

binocular/ui/preview/
mod.rs

1mod plain_text;
2
3use crate::app::{InputMode, Mode};
4use crate::preview::{self, PreviewContent, PreviewSource};
5use ratatui::{
6    layout::Rect,
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, BorderType, Borders},
10    Frame,
11};
12
13use crate::ui::indicators::mode_indicator;
14use crate::ui::shortcuts::{preview_hints, render_hints_line};
15
16pub struct PreviewView<'a> {
17    pub app_mode: Mode,
18    pub preview_mode: InputMode,
19    pub source: Option<&'a PreviewSource>,
20    pub status_message: Option<(&'a str, std::time::Instant)>,
21    pub command_buffer: Option<&'a str>,
22    pub highlight_line: Option<usize>,
23    pub search_query: &'a str,
24    pub selection_start: Option<(usize, usize)>,
25    pub cursor_line: usize,
26    pub cursor_char: usize,
27    pub scroll: u16,
28    pub scroll_char: u16,
29    pub area_height: u16,
30}
31
32pub fn render_preview(
33    f: &mut Frame,
34    view: &PreviewView<'_>,
35    content: Option<&mut PreviewContent>,
36    area: Rect,
37) {
38    let is_read_only = is_read_only_preview(content.as_deref());
39    let preview_style = if is_read_only {
40        Style::default().fg(Color::Yellow)
41    } else if view.app_mode == Mode::Preview {
42        Style::default().fg(Color::Blue)
43    } else {
44        Style::default()
45    };
46
47    let status_line = build_status_line(view, content.as_deref());
48    let hints = render_hints_line(preview_hints(view.app_mode, view.preview_mode));
49    let preview_block = Block::default()
50        .borders(Borders::ALL)
51        .border_type(BorderType::Rounded)
52        .title(build_preview_title(view.source).centered())
53        .title_bottom(status_line)
54        .title_bottom(hints.right_aligned())
55        .border_style(preview_style);
56
57    f.render_widget(ratatui::widgets::Clear, area);
58
59    match content {
60        Some(PreviewContent::RichText(text_file)) => {
61            preview::rich_text::ui::render_rich_text_preview(
62                f,
63                area,
64                preview_block,
65                text_file,
66                view,
67            );
68        }
69        Some(PreviewContent::Diff(diff)) => {
70            plain_text::render_plain_text_preview(f, area, preview_block, diff.text.clone(), view);
71        }
72        Some(PreviewContent::PlainText(content)) => {
73            plain_text::render_plain_text_preview(f, area, preview_block, content.clone(), view);
74        }
75        Some(PreviewContent::Image(image)) => {
76            preview::image::ui::render_image_preview(f, area, preview_block, image, view);
77        }
78        Some(PreviewContent::Media(media)) => {
79            preview::media::ui::render_media_preview(f, area, preview_block, media, view);
80        }
81        Some(PreviewContent::StructuredLog(lp)) => {
82            let inner = preview_block.inner(area);
83            f.render_widget(preview_block, area);
84            preview::structured_log::ui::render_structured_log(f, inner, lp);
85        }
86        None => {
87            f.render_widget(preview_block, area);
88        }
89    }
90}
91
92fn build_preview_title(source: Option<&PreviewSource>) -> Line<'static> {
93    let Some(source) = source else {
94        return Line::from("Preview");
95    };
96    let title = source.title();
97    Line::from(vec![
98        Span::raw(" "),
99        Span::styled(
100            shorten_path(title.as_ref()),
101            Style::default().add_modifier(Modifier::BOLD),
102        ),
103        Span::raw(" "),
104    ])
105}
106
107/// Show `…/parent/name` for deep paths, or just `name` for shallow ones.
108fn shorten_path(path: &str) -> String {
109    use std::path::Path;
110    let stripped = path.strip_prefix("./").unwrap_or(path);
111    let p = Path::new(stripped);
112    let name = match p.file_name() {
113        Some(n) => n.to_string_lossy().into_owned(),
114        None => return stripped.to_string(),
115    };
116    match p.parent().filter(|par| *par != Path::new("")) {
117        None => name,
118        Some(parent) => {
119            let parent_name = parent
120                .file_name()
121                .map(|n| n.to_string_lossy().into_owned())
122                .unwrap_or_else(|| parent.to_string_lossy().into_owned());
123            format!("{}/{}", parent_name, name)
124        }
125    }
126}
127
128fn build_status_line(view: &PreviewView<'_>, content: Option<&PreviewContent>) -> Line<'static> {
129    if is_read_only_preview(content) {
130        return Line::from(vec![Span::styled(
131            " READ-ONLY ",
132            Style::default().fg(Color::Yellow),
133        )]);
134    }
135
136    if let Some((msg, time)) = view.status_message {
137        if time.elapsed().as_secs() < 3 {
138            return Line::from(vec![Span::styled(
139                format!(" {} ", msg),
140                Style::default().fg(Color::Green),
141            )]);
142        }
143    }
144
145    Line::from(vec![
146        Span::raw(" "),
147        mode_indicator(&view.preview_mode, view.command_buffer),
148    ])
149}
150
151fn is_read_only_preview(content: Option<&PreviewContent>) -> bool {
152    matches!(
153        content,
154        Some(PreviewContent::Diff(_))
155            | Some(PreviewContent::PlainText(_))
156            | Some(PreviewContent::Image(_))
157            | Some(PreviewContent::Media(_))
158    )
159}