use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
use ratatui::Frame;
use ratatui_image::StatefulImage;
use crate::app::state::{AppState, PreviewSlot};
use crate::preview::provider::{line_number_gutter, PreviewContent};
use crate::ui::theme;
pub fn draw(frame: &mut Frame, area: Rect, state: &mut AppState) {
let styles = theme::resolve(&state.config.theme);
let title = if let Some(entry) = state.selected_entry() {
format!(" {} ", entry.file_name)
} else {
" Preview ".to_owned()
};
let block = Block::default()
.title(title.clone())
.borders(Borders::ALL)
.border_style(styles.border)
.border_type(BorderType::Rounded);
let badge_style = if crate::version::is_release() {
styles.status
} else {
styles.git_dirty
};
let block = match version_badge(state, title.chars().count(), area.width) {
Some(badge) => block
.title_top(Line::from(Span::styled(format!(" {badge} "), badge_style)).right_aligned()),
None => block,
};
let inner = block.inner(area);
let height = usize::from(inner.height);
state.preview.viewport_height = height;
state.preview.viewport_width = inner.width;
state.preview.clamp_scroll();
let offset = state.preview.scroll;
let block = match scroll_footer(&state.preview) {
Some(footer) => {
block.title_bottom(Line::from(Span::styled(footer, styles.status)).right_aligned())
}
None => block,
};
match &mut state.preview.content {
PreviewContent::Empty => {
frame.render_widget(block, area);
}
PreviewContent::Loading => {
let p = Paragraph::new("Loading…")
.style(
Style::default()
.fg(theme::parse_color(&state.config.theme.hidden))
.add_modifier(Modifier::ITALIC),
)
.block(block);
frame.render_widget(p, area);
}
PreviewContent::Text(lines) => {
let text: Vec<Line> = lines
.iter()
.skip(offset)
.take(height)
.map(|l| {
if let Some((num_part, rest)) = l.split_once(" ") {
Line::from(vec![
Span::styled(format!("{num_part} "), styles.status),
Span::raw(rest.to_owned()),
])
} else {
Line::from(Span::raw(l.as_str()))
}
})
.collect();
let p = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
frame.render_widget(p, area);
}
PreviewContent::Highlighted(lines) => {
let text: Vec<Line> = lines
.iter()
.enumerate()
.skip(offset)
.take(height)
.map(|(idx, spans)| {
let mut ratatui_spans = Vec::with_capacity(spans.len() + 1);
ratatui_spans.push(Span::styled(line_number_gutter(idx + 1), styles.status));
for s in spans {
let style = if let Some(fg) = s.fg {
Style::default().fg(fg)
} else {
Style::default()
};
ratatui_spans.push(Span::styled(s.text.clone(), style));
}
Line::from(ratatui_spans)
})
.collect();
let p = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
frame.render_widget(p, area);
}
PreviewContent::Binary(lines) => {
let text: Vec<Line> = lines
.iter()
.skip(offset)
.take(height)
.map(|l| {
if l.is_empty() {
Line::from("")
} else if let Some((label, value)) = l.split_once(':') {
Line::from(vec![
Span::styled(
format!("{label}:"),
styles.command.add_modifier(Modifier::BOLD),
),
Span::raw(value.to_owned()),
])
} else {
Line::from(Span::styled(l.as_str(), styles.status))
}
})
.collect();
let p = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
frame.render_widget(p, area);
}
PreviewContent::Image(preview) => {
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let (image_area, caption_area) = if inner.height > 1 {
(
Rect {
height: inner.height - 1,
..inner
},
Some(Rect {
y: inner.y + inner.height - 1,
height: 1,
..inner
}),
)
} else {
(inner, None)
};
frame.render_stateful_widget(
StatefulImage::new(None),
image_area,
&mut preview.protocol,
);
if let Some(caption_area) = caption_area {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
preview.caption.as_str(),
styles.status,
))),
caption_area,
);
}
}
PreviewContent::Directory {
file_count,
dir_count,
hidden_count,
entries,
} => {
let mut lines: Vec<Line> = Vec::with_capacity(entries.len() + 4);
lines.push(Line::from(vec![
Span::styled(format!("{dir_count}"), styles.directory),
Span::raw(" dirs, "),
Span::styled(format!("{file_count}"), styles.git_clean),
Span::raw(" files"),
if *hidden_count > 0 {
Span::styled(format!(", {hidden_count} hidden"), styles.status)
} else {
Span::raw("")
},
]));
lines.push(Line::from(""));
for name in entries {
let style = if name.ends_with('/') {
styles.directory
} else {
Style::default()
};
lines.push(Line::from(Span::styled(format!(" {name}"), style)));
}
let visible: Vec<Line> = lines.into_iter().skip(offset).take(height).collect();
let p = Paragraph::new(visible).block(block);
frame.render_widget(p, area);
}
}
}
fn version_badge(state: &AppState, title_width: usize, area_width: u16) -> Option<String> {
if !state.config.general.show_version {
return None;
}
let badge = format!("v{}", crate::version::VERSION);
let inner = usize::from(area_width).saturating_sub(2);
(title_width + badge.chars().count() + 3 <= inner).then_some(badge)
}
fn scroll_footer(slot: &PreviewSlot) -> Option<String> {
let total = slot.content.scrollable_len()?;
if slot.scroll == 0 && slot.max_scroll() == 0 && !slot.truncated {
return None;
}
let first = slot.scroll + 1;
let last = (slot.scroll + slot.lines_visible_from(slot.scroll)).min(total);
let more = if slot.truncated { "+" } else { "" };
Some(format!(" {first}–{last}/{total}{more} "))
}