use std::path::Path;
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Padding, Paragraph};
use ratatui_image::StatefulImage;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use super::{codeview, syntax, theme};
use crate::app::{Focus, Loaded};
const MAX_TEXT_BYTES: u64 = 256 * 1024;
const MAX_TEXT_LINES: usize = 500;
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
#[derive(Default)]
pub enum Preview {
#[default]
Empty,
Text(Vec<Line<'static>>),
Image(Box<StatefulProtocol>),
Info(String),
}
pub fn load(path: &Path, picker: Option<&Picker>) -> Preview {
if image::ImageFormat::from_path(path).is_ok() {
load_image(path, picker)
} else {
load_text(path)
}
}
fn load_image(path: &Path, picker: Option<&Picker>) -> Preview {
match decode_image(path, picker) {
Ok(protocol) => Preview::Image(protocol),
Err(message) => Preview::Info(message),
}
}
pub(super) fn decode_image(
path: &Path,
picker: Option<&Picker>,
) -> Result<Box<StatefulProtocol>, String> {
let Some(picker) = picker else {
return Err("image preview is not supported by this terminal".into());
};
match std::fs::metadata(path) {
Ok(meta) if meta.len() > MAX_IMAGE_BYTES => {
return Err(format!(
"image too large to preview ({})",
theme::human_bytes(meta.len())
));
}
Ok(_) => {}
Err(err) => return Err(format!("cannot read image: {err}")),
}
let reader = image::ImageReader::open(path)
.and_then(|r| r.with_guessed_format())
.map_err(|err| format!("cannot read image: {err}"))?;
let img = reader
.decode()
.map_err(|err| format!("cannot decode image: {err}"))?;
Ok(Box::new(picker.new_resize_protocol(img)))
}
fn load_text(path: &Path) -> Preview {
use std::io::Read;
let file = match std::fs::File::open(path) {
Ok(file) => file,
Err(err) => return Preview::Info(format!("cannot read file: {err}")),
};
let mut buf = Vec::new();
if let Err(err) = file.take(MAX_TEXT_BYTES).read_to_end(&mut buf) {
return Preview::Info(format!("cannot read file: {err}"));
}
if buf.contains(&0) {
return Preview::Info("binary file — no preview".into());
}
let text = String::from_utf8_lossy(&buf);
let mut truncated = String::new();
for line in text.lines().take(MAX_TEXT_LINES) {
truncated.push_str(line);
truncated.push('\n');
}
if truncated.trim().is_empty() {
return Preview::Info("empty file".into());
}
Preview::Text(syntax::highlight(&truncated, path))
}
pub fn render(frame: &mut Frame, loaded: &mut Loaded, area: Rect) {
let focused = loaded.focus == Focus::Preview;
if matches!(loaded.preview, Preview::Text(_)) {
codeview::render(frame, &mut loaded.preview_view, area, " preview ", focused);
return;
}
let border = if focused { theme::ACCENT } else { theme::MUTED };
let block = Block::bordered()
.title(" preview ")
.border_style(Style::default().fg(border))
.padding(Padding::horizontal(1));
let inner = block.inner(area);
frame.render_widget(block, area);
match &mut loaded.preview {
Preview::Empty | Preview::Text(_) => {}
Preview::Image(protocol) => {
frame.render_stateful_widget(
StatefulImage::<StatefulProtocol>::new(),
inner,
protocol.as_mut(),
);
}
Preview::Info(message) => {
let note = Paragraph::new(Line::from(Span::styled(
message.clone(),
Style::default()
.fg(theme::MUTED)
.add_modifier(Modifier::ITALIC),
)))
.alignment(Alignment::Center);
frame.render_widget(note, inner);
}
}
}