mod lens;
pub use lens::{BandContext, BlameMark};
mod motion;
mod find;
use find::{FindState, chip_line};
use super::pane::{Entry, EntryKind};
use crate::components::modeline::fit_middle;
use crate::sanitize;
use crate::theme::Theme;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Default)]
pub enum PreviewContent {
#[default]
Empty,
Text(String),
Highlighted(Vec<Line<'static>>),
DirSummary(Vec<Entry>),
Binary {
size: usize,
},
}
pub struct Preview {
pub content: PreviewContent,
pub title: String,
pub focused: bool,
scroll: u16,
cursor: u16,
line_count: u16,
numbered: bool,
lang: Option<String>,
find: Option<FindState>,
band_path: Option<String>,
band_context: Option<BandContext>,
visual_anchor: Option<u16>,
blame: Option<Vec<Option<BlameMark>>>,
motion_count: String,
motion_pending: Option<char>,
viewport: u16,
}
impl Default for Preview {
fn default() -> Self {
Self::new()
}
}
impl Preview {
pub fn new() -> Self {
Preview {
content: PreviewContent::Empty,
title: "preview".into(),
focused: false,
scroll: 0,
cursor: 0,
line_count: 0,
numbered: false,
lang: None,
find: None,
band_path: None,
band_context: None,
visual_anchor: None,
blame: None,
motion_count: String::new(),
motion_pending: None,
viewport: 0,
}
}
pub fn text_line_count(&self) -> usize {
self.line_count as usize
}
pub fn content_text(&self) -> Option<String> {
match &self.content {
PreviewContent::Text(text) => Some(text.clone()),
PreviewContent::Highlighted(lines) => Some(
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect::<Vec<String>>()
.join("\n"),
),
_ => None,
}
}
pub fn copy_target(&self) -> Option<(String, usize)> {
let lines: Vec<String> = self.content_text()?.lines().map(str::to_string).collect();
let (lo, hi) = match self.visual_range() {
Some((a, b)) => (a as usize, b as usize),
None => {
let l = self.line()? as usize;
(l, l)
}
};
let slice: Vec<String> = lines
.iter()
.enumerate()
.filter(|(i, _)| (*i + 1) >= lo && (*i + 1) <= hi)
.map(|(_, l)| l.clone())
.collect();
(!slice.is_empty()).then(|| {
let mut text = slice.join("\n");
text.push('\n');
(text, slice.len())
})
}
pub fn focused() -> Self {
Preview {
focused: true,
..Preview::new()
}
}
pub fn set_bytes(&mut self, name: &str, bytes: &[u8]) {
self.title = sanitize::sanitize_inline(name);
self.lang = None;
if sanitize::is_binary(bytes) {
self.content = PreviewContent::Binary { size: bytes.len() };
self.line_count = 0;
self.numbered = false;
} else {
let text = sanitize::sanitize(bytes);
self.line_count = text.lines().count() as u16;
self.content = PreviewContent::Text(text);
self.numbered = true;
}
self.reset();
}
pub fn set_dir(&mut self, name: &str, children: Vec<Entry>) {
self.title = format!("{}/", sanitize::sanitize_inline(name));
self.content = PreviewContent::DirSummary(children);
self.line_count = 0;
self.numbered = false;
self.lang = None;
self.reset();
}
fn set_meta_text(&mut self, name: &str, size: Option<u64>, sha: &str, tail: &str) {
self.title = sanitize::sanitize_inline(name);
let size = size.map(|s| s.to_string()).unwrap_or_else(|| "?".into());
let short = &sha[..sha.len().min(7)];
let text = format!("{size} bytes · blob {short}\n\n{tail}");
self.line_count = text.lines().count() as u16;
self.content = PreviewContent::Text(text);
self.numbered = false;
self.lang = None;
self.reset();
}
pub fn set_file_meta(&mut self, name: &str, size: Option<u64>, sha: &str) {
self.set_meta_text(name, size, sha, "loading…");
}
pub fn set_error(&mut self, name: &str, size: Option<u64>, sha: &str, message: &str) {
self.set_meta_text(name, size, sha, &format!("error: {message}"));
}
pub fn set_highlighted(&mut self, name: &str, lang: &str, lines: Vec<Line<'static>>) {
self.title = sanitize::sanitize_inline(name);
self.line_count = lines.len() as u16;
self.content = PreviewContent::Highlighted(lines);
self.numbered = true;
self.lang = Some(lang.to_string());
self.reset();
}
fn plain_lines(&self) -> Vec<String> {
match &self.content {
PreviewContent::Text(text) => text.lines().map(|l| l.replace('\t', " ")).collect(),
PreviewContent::Highlighted(lines) => lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect(),
_ => vec![],
}
}
fn readout(&self) -> Option<String> {
if self.line_count == 0 {
return None;
}
let pos = format!("{}/{}", self.cursor + 1, self.line_count);
let pos = match self.visual_range() {
Some((lo, hi)) => format!("VISUAL {lo}-{hi} · {pos}"),
None => pos,
};
match &self.find {
Some(f) if !f.query.is_empty() => {
let cur = if f.matches.is_empty() {
0
} else {
f.current + 1
};
Some(format!("{cur}/{} · {pos}", f.matches.len()))
}
_ => Some(pos),
}
}
fn reset(&mut self) {
self.band_path = None;
self.band_context = None;
self.visual_anchor = None;
self.scroll = 0;
self.cursor = 0;
self.find = None;
}
fn clamp_scroll(&mut self, viewport: u16) {
if self.line_count == 0 || viewport == 0 {
return;
}
if self.cursor < self.scroll {
self.scroll = self.cursor;
} else if self.cursor >= self.scroll + viewport {
self.scroll = self.cursor + 1 - viewport;
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let sem = &theme.semantic;
let title = match &self.find {
Some(f) => format!(" {} /{} ", self.title, f.query),
None => format!(" {} ", self.title),
};
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type())
.border_style(Style::default().fg(if self.focused {
sem.border_focused
} else {
sem.border_unfocused
}))
.style(Style::default().bg(sem.base))
.title(Span::styled(title, Style::default().fg(sem.subtext0)));
if let Some(readout) = self.readout() {
block = block.title_bottom(
Line::from(Span::styled(
format!(" {readout} "),
Style::default().fg(sem.subtext0),
))
.right_aligned(),
);
}
if self.numbered {
let meta = match &self.lang {
Some(lang) => format!(" {lang} · {} lines ", self.line_count),
None => format!(" {} lines ", self.line_count),
};
block = block.title_bottom(
Line::from(Span::styled(meta, Style::default().fg(sem.overlay0))).left_aligned(),
);
}
let cursored = self.line_count > 0;
let cursor = self.cursor as usize;
let mut lines: Vec<Line> = match &self.content {
PreviewContent::Empty => {
vec![Line::from(Span::styled(
"nothing selected",
Style::default().fg(sem.overlay0),
))]
}
PreviewContent::Highlighted(lines) => lines.clone(),
PreviewContent::Text(text) => text
.lines()
.map(|l| {
Line::from(Span::styled(
l.replace('\t', " "),
Style::default().fg(sem.text),
))
})
.collect(),
PreviewContent::DirSummary(children) => children
.iter()
.map(|e| match e.kind {
EntryKind::File => {
Line::from(Span::styled(e.name.clone(), Style::default().fg(sem.file)))
}
_ => Line::from(Span::styled(
format!("{}/", e.name),
Style::default()
.fg(sem.directory)
.add_modifier(Modifier::BOLD),
)),
})
.collect(),
PreviewContent::Binary { size } => vec![Line::from(Span::styled(
format!("binary file · {} bytes", size),
Style::default().fg(sem.warning),
))],
};
if self.numbered
&& let Some(find) = &self.find
&& !find.matches.is_empty()
{
let match_style = Style::default()
.fg(sem.crust)
.bg(sem.search_match)
.add_modifier(Modifier::BOLD);
let current_style = Style::default()
.fg(sem.crust)
.bg(sem.warning)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
for (i, line) in lines.iter_mut().enumerate() {
let ranges: Vec<(usize, usize, bool)> = find
.matches
.iter()
.enumerate()
.filter(|(_, m)| m.line as usize == i)
.map(|(idx, m)| (m.start, m.end, idx == find.current))
.collect();
if !ranges.is_empty() {
*line = chip_line(line, &ranges, match_style, current_style);
}
}
}
if cursored && let Some(line) = lines.get_mut(cursor) {
line.style = Style::default().bg(sem.selection_bg);
}
if let Some((lo, hi)) = self.visual_range() {
for i in (lo as usize - 1)..=(hi as usize - 1).min(lines.len().saturating_sub(1)) {
if let Some(line) = lines.get_mut(i) {
line.style = Style::default().bg(sem.selection_bg);
}
}
}
if self.numbered {
let width = self.line_count.max(1).to_string().len();
for (i, line) in lines.iter_mut().enumerate() {
let cursor_line = i == cursor;
let num_style = if cursor_line {
Style::default().fg(sem.text).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(sem.overlay0)
};
line.spans
.insert(0, Span::styled(format!("{:>width$}", i + 1), num_style));
line.spans.insert(
0,
Span::styled(
if cursor_line { "▶ " } else { " " },
if cursor_line {
Style::default()
.fg(sem.border_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
),
);
line.spans
.insert(2, Span::styled(" │ ", Style::default().fg(sem.overlay0)));
}
}
if self.numbered
&& let Some(blame) = &self.blame
{
for (i, line) in lines.iter_mut().enumerate() {
let spans: Vec<Span<'static>> = match blame.get(i).cloned().flatten() {
Some(m) => vec![
Span::styled(m.sha, Style::default().fg(sem.warning)),
Span::styled(
format!(" {:<8.8}", m.author),
Style::default().fg(sem.subtext0),
),
Span::styled(" │ ".to_string(), Style::default().fg(sem.overlay0)),
],
None => vec![Span::styled(
" · ".to_string() + " │ ",
Style::default().fg(sem.overlay0),
)],
};
for (j, sp) in spans.into_iter().enumerate() {
line.spans.insert(j, sp);
}
}
}
let band = self.numbered && self.band_path.is_some();
let inner = block.inner(area);
self.viewport = inner.height.saturating_sub(band as u16);
self.clamp_scroll(self.viewport);
frame.render_widget(block, area);
let content_area = if band {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let path = self.band_path.clone().unwrap_or_default();
let mut spans = vec![Span::styled(
format!(" {path}"),
Style::default()
.fg(sem.text)
.bg(sem.surface0)
.add_modifier(Modifier::BOLD),
)];
let left_w = 1 + UnicodeWidthStr::width(path.as_str());
if let Some(ctx) = &self.band_context {
let room = (inner.width as usize).saturating_sub(left_w + 1);
let full = format!(
"{} · {} · {} · {}",
ctx.sha, ctx.subject, ctx.author, ctx.date
);
let short = format!("{} · {} · {}", ctx.sha, ctx.subject, ctx.author);
let shorter = format!("{} · {}", ctx.sha, ctx.subject);
let fits = |s: &str| UnicodeWidthStr::width(s) <= room;
let text = if fits(&full) {
full
} else if fits(&short) {
short
} else if fits(&shorter) {
shorter
} else {
fit_middle(&ctx.sha, room)
};
let pad = room.saturating_sub(UnicodeWidthStr::width(text.as_str()));
spans.push(Span::styled(
" ".repeat(pad + 1),
Style::default().bg(sem.surface0),
));
spans.push(Span::styled(
ctx.sha
.chars()
.take(lens::sha_len(&text, ctx))
.collect::<String>(),
Style::default().fg(sem.warning).bg(sem.surface0),
));
spans.push(Span::styled(
text[lens::sha_len(&text, ctx)..].to_string(),
Style::default().fg(sem.subtext0).bg(sem.surface0),
));
}
let band_line = Line::from(spans);
let band_w: usize = band_line.spans.iter().map(|s| s.content.width()).sum();
let mut band_spans = band_line.spans;
band_spans.push(Span::styled(
" ".repeat((inner.width as usize).saturating_sub(band_w)),
Style::default().bg(sem.surface0),
));
frame.render_widget(Paragraph::new(Line::from(band_spans)), rows[0]);
rows[1]
} else {
inner
};
frame.render_widget(
Paragraph::new(lines)
.scroll((self.scroll, 0))
.wrap(Wrap { trim: false }),
content_area,
);
if self.numbered {
super::scrollbar(
frame,
area,
self.viewport as usize,
self.line_count as usize,
self.scroll as usize,
theme,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent {
modifiers: KeyModifiers::CONTROL,
..key(code)
}
}
fn motion_preview() -> Preview {
let mut lines: Vec<String> = (1..=20).map(|i| format!("line {i}")).collect();
lines[4] = String::new();
lines[11] = String::new();
lines[6] = "fn main() {".into();
lines[14] = "}".into();
let body = lines.join("\n");
let mut p = Preview::new();
p.set_bytes("a.rs", body.as_bytes());
p.viewport = 10;
p
}
fn motions(p: &mut Preview, keys: &str) {
for c in keys.chars() {
assert!(p.motion_key(key(KeyCode::Char(c))), "motion {c}");
}
}
#[test]
fn counts_gg_g_and_pages() {
let mut p = motion_preview();
motions(&mut p, "3j");
assert_eq!(p.line(), Some(4));
motions(&mut p, "gg");
assert_eq!(p.line(), Some(1));
motions(&mut p, "5G");
assert_eq!(p.line(), Some(5));
motions(&mut p, "G");
assert_eq!(p.line(), Some(20));
assert!(p.motion_key(ctrl(KeyCode::Char('u'))));
assert_eq!(p.line(), Some(15));
assert!(p.motion_key(ctrl(KeyCode::Char('d'))));
assert_eq!(p.line(), Some(20));
assert!(p.motion_key(ctrl(KeyCode::Char('b'))));
assert_eq!(p.line(), Some(10));
for c in "7".chars() {
p.motion_key(key(KeyCode::Char(c)));
}
assert!(!p.motion_key(key(KeyCode::Char('x'))));
motions(&mut p, "j");
assert_eq!(p.line(), Some(11));
motions(&mut p, "g");
motions(&mut p, "j");
assert_eq!(p.line(), Some(12));
assert_eq!(p.motion_pending, None);
}
#[test]
fn paragraphs_brackets_and_view_positioning() {
let mut p = motion_preview();
motions(&mut p, "8G"); motions(&mut p, "{");
assert_eq!(p.line(), Some(5)); motions(&mut p, "}");
assert_eq!(p.line(), Some(12)); motions(&mut p, "7G");
motions(&mut p, "%");
assert_eq!(p.line(), Some(15));
motions(&mut p, "%");
assert_eq!(p.line(), Some(7));
motions(&mut p, "15G");
motions(&mut p, "zz");
assert_eq!(p.scroll, 9); motions(&mut p, "zt");
assert_eq!(p.scroll, 14);
motions(&mut p, "zb");
assert_eq!(p.scroll, 5); }
#[test]
fn motions_noop_on_cursorless_content() {
let mut p = Preview::new();
assert!(!p.motion_key(key(KeyCode::Char('j'))));
assert!(!p.motion_key(key(KeyCode::Char('G'))));
}
#[test]
fn visual_selects_and_copy_targets_it() {
let mut p = motion_preview();
p.move_cursor(1);
let (text, n) = p.copy_target().unwrap();
assert_eq!((text.as_str(), n), ("line 2\n", 1));
assert_eq!(p.visual_range(), None);
p.toggle_visual();
assert_eq!(p.visual_range(), Some((2, 2)));
p.move_cursor(2);
assert_eq!(p.visual_range(), Some((2, 4)));
let (text, n) = p.copy_target().unwrap();
assert_eq!(n, 3);
assert!(text.starts_with("line 2") && text.ends_with("line 4\n"));
motions(&mut p, "gg");
assert_eq!(p.visual_range(), Some((1, 2)));
assert!(p.clear_visual());
assert_eq!(p.visual_range(), None);
assert!(!p.clear_visual());
}
#[test]
fn cursor_walks_and_clamps() {
let mut p = Preview::new();
p.set_bytes("a.rs", b"one\ntwo\nthree");
assert_eq!(p.line(), Some(1));
p.move_cursor(1);
p.move_cursor(1);
assert_eq!(p.line(), Some(3));
p.move_cursor(1); assert_eq!(p.line(), Some(3));
p.move_cursor(-10); assert_eq!(p.line(), Some(1));
}
#[test]
fn cursorless_content_has_no_line() {
let mut p = Preview::new();
p.set_dir("src", vec![]);
assert_eq!(p.line(), None);
p.move_cursor(1);
assert_eq!(p.line(), None);
p.set_bytes("blob", b"\0\0binary\0");
assert_eq!(p.line(), None);
assert_eq!(p.readout(), None);
}
#[test]
fn cursor_resets_on_new_content() {
let mut p = Preview::new();
p.set_bytes("a.rs", b"one\ntwo\nthree");
p.move_cursor(2);
assert_eq!(p.line(), Some(3));
p.set_highlighted("b.rs", "rust", vec![Line::from("x")]);
assert_eq!(p.line(), Some(1));
assert_eq!(p.readout().as_deref(), Some("1/1"));
}
#[test]
fn scroll_follows_cursor_into_viewport() {
let mut p = Preview::new();
p.set_bytes(
"a.rs",
(1..=50)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n")
.as_bytes(),
);
for _ in 0..49 {
p.move_cursor(1);
}
p.clamp_scroll(10);
assert_eq!(p.scroll, 40); p.move_cursor(-49);
p.clamp_scroll(10);
assert_eq!(p.scroll, 0);
}
}