use crate::constants::{CELL_ASPECT_FALLBACK, CELL_ASPECT_RANGE, HEX_BYTES_PER_LINE, HEX_OFFSET_DIGITS, IMAGE_COLOR_DROP_BITS, IMAGE_GLYPH_CONTRAST, IMAGE_MAX_OVERFLOW, IMAGE_MAX_SIDE, IMAGE_RAMP, IMAGE_ZOOM_NORMAL, PREVIEW_HEX_BYTES};
use image::DynamicImage;
use image::imageops::FilterType;
use ratatui::style::Color;
use ratatui::text::Span;
use std::fs::File;
use std::io::{Error, Read};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use syntect::highlighting::{HighlightIterator, HighlightState, Highlighter, ThemeSet};
use syntect::parsing::{ParseState, ScopeStack, SyntaxSet};
mod hex;
mod highlight;
mod picture;
#[cfg(test)]
mod tests;
pub use self::{hex::*, highlight::*, picture::*};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ViewMode {
Text,
Hex,
Image,
}
pub struct ViewerState {
pub file_path: PathBuf,
pub content_lines: Vec<String>,
pub scroll_offset: usize,
pub horizontal_offset: usize,
pub total_lines: usize,
pub file_size: u64,
pub syntax_name: String,
pub from_edit: bool,
pub bytes: Vec<u8>,
pub mode: ViewMode,
pub selection: Option<((usize, usize), (usize, usize))>,
pub max_line_width: usize,
pub image: Option<DynamicImage>,
pub image_lines: Vec<String>,
pub image_colors: Vec<Vec<Color>>,
pub image_backgrounds: Vec<Vec<Color>>,
pub image_columns: usize,
pub image_fill: bool,
pub pan_from: Option<((u16, u16), (usize, usize))>,
pub image_zoom: u16,
pub wrap: bool,
pub rows: Vec<(usize, usize, usize)>,
pub rows_width: usize,
}
pub fn is_binary_file(path: &Path) -> Result<bool, Error> {
let mut file = File::open(path)?;
let mut buffer = [0; 512];
let bytes_read = file.read(&mut buffer)?;
Ok(looks_binary(&buffer[..bytes_read]))
}
fn looks_binary(head: &[u8]) -> bool {
let head = &head[..head.len().min(512)];
if head.contains(&0) {
return true;
}
match std::str::from_utf8(head) {
Ok(_) => false,
Err(e) => e.error_len().is_some(),
}
}
pub fn load_file_content(path: &Path, decoded: Option<(DynamicImage, String)>) -> Result<ViewerState, Error> {
let metadata = std::fs::metadata(path)?;
if !metadata.is_file() {
return Err(Error::new(std::io::ErrorKind::InvalidInput, t!("Not a regular file: {path}", path = path.display())));
}
let file_size = metadata.len();
if is_binary_file(path)? {
let bytes = std::fs::read(path)?;
if let Some((image, syntax_name)) = decoded.or_else(|| load_image(&bytes)) {
return Ok(ViewerState {
file_path: path.to_path_buf(),
content_lines: Vec::new(),
scroll_offset: 0,
horizontal_offset: 0,
total_lines: 1,
file_size,
syntax_name,
from_edit: false,
bytes,
mode: ViewMode::Image,
selection: None,
max_line_width: 0,
image: Some(image),
image_columns: 0,
image_fill: false,
pan_from: None,
image_zoom: IMAGE_ZOOM_NORMAL,
image_lines: Vec::new(),
image_colors: Vec::new(),
image_backgrounds: Vec::new(),
wrap: false,
rows: Vec::new(),
rows_width: 0,
});
}
return Ok(ViewerState {
file_path: path.to_path_buf(),
content_lines: Vec::new(),
scroll_offset: 0,
horizontal_offset: 0,
total_lines: hex_line_count(&bytes),
file_size,
syntax_name: t!("Binary").to_string(),
from_edit: false,
bytes,
mode: ViewMode::Hex,
selection: None,
max_line_width: 0,
image: None,
image_columns: 0,
image_fill: false,
pan_from: None,
image_zoom: IMAGE_ZOOM_NORMAL,
image_lines: Vec::new(),
image_colors: Vec::new(),
image_backgrounds: Vec::new(),
wrap: false,
rows: Vec::new(),
rows_width: 0,
});
}
let content = match String::from_utf8(std::fs::read(path)?) {
Ok(content) => content,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
};
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
if content.ends_with('\n') {
lines.push(String::new());
}
if lines.is_empty() {
lines.push(String::new());
}
let total_lines = lines.len();
let max_line_width = lines.iter().map(|line| crate::utils::line_display_width(line)).max().unwrap_or(0);
let syntax_name = detect_syntax(path);
Ok(ViewerState {
file_path: path.to_path_buf(),
content_lines: lines,
scroll_offset: 0,
horizontal_offset: 0,
total_lines,
file_size,
from_edit: false,
syntax_name,
bytes: Vec::new(),
mode: ViewMode::Text,
selection: None,
max_line_width,
image: None,
image_columns: 0,
image_fill: false,
pan_from: None,
image_zoom: IMAGE_ZOOM_NORMAL,
image_lines: Vec::new(),
image_colors: Vec::new(),
image_backgrounds: Vec::new(),
wrap: false,
rows: Vec::new(),
rows_width: 0,
})
}
pub enum Preview {
Lines(Vec<String>),
Bytes(Vec<u8>),
}
pub fn load_preview(path: &Path, max_bytes: u64, max_lines: usize) -> Preview {
if !crate::fs_ops::is_regular_file(path) {
return Preview::Lines(vec![t!("Not a regular file").to_string()]);
}
let binary = is_binary_file(path).unwrap_or(false);
let Ok(file) = File::open(path) else {
return unreadable_preview();
};
let mut buffer = Vec::new();
if file.take(if binary { PREVIEW_HEX_BYTES } else { max_bytes }).read_to_end(&mut buffer).is_err() {
return unreadable_preview();
}
preview_of(buffer, max_lines)
}
pub fn unreadable_preview() -> Preview {
Preview::Lines(vec![t!("Cannot read file").to_string()])
}
pub fn preview_of(mut head: Vec<u8>, max_lines: usize) -> Preview {
if looks_binary(&head) {
head.truncate(PREVIEW_HEX_BYTES as usize);
return Preview::Bytes(head);
}
Preview::Lines(String::from_utf8_lossy(&head).lines().take(max_lines).map(|line| line.to_string()).collect())
}
impl ViewerState {
pub fn selected_range(&self) -> Option<((usize, usize), (usize, usize))> {
let (anchor, cursor) = self.selection?;
if anchor == cursor {
return None;
}
Some(if anchor < cursor { (anchor, cursor) } else { (cursor, anchor) })
}
pub fn selected_text(&self) -> Option<String> {
let ((first_line, first_col), (last_line, last_col)) = self.selected_range()?;
let mut text = String::new();
for index in first_line..=last_line {
let characters: Vec<char> = self.line_text(index).chars().collect();
let to = if index == last_line { last_col.min(characters.len()) } else { characters.len() };
let from = if index == first_line { first_col.min(to) } else { 0 };
if index > first_line {
text.push('\n');
}
text.extend(&characters[from..to]);
}
Some(text)
}
pub fn line_text(&self, index: usize) -> String {
match self.mode {
ViewMode::Hex => {
let offset = index * HEX_BYTES_PER_LINE;
let stop = (offset + HEX_BYTES_PER_LINE).min(self.bytes.len());
hex_line(offset, self.bytes.get(offset..stop).unwrap_or(&[]))
}
ViewMode::Image => self.image_lines.get(index).cloned().unwrap_or_default(),
ViewMode::Text => self.content_lines.get(index).map(|line| crate::utils::printable_line(line)).unwrap_or_default(),
}
}
pub fn line_count(&self) -> usize {
match self.mode {
ViewMode::Hex => hex_line_count(&self.bytes),
ViewMode::Image => self.image_lines.len().max(1),
ViewMode::Text => self.content_lines.len().max(1),
}
}
pub fn content_width(&self) -> usize {
match self.mode {
ViewMode::Hex => crate::constants::HEX_LINE_WIDTH,
ViewMode::Image => self.image_columns,
ViewMode::Text => self.max_line_width,
}
}
pub fn wrapping(&self) -> bool {
self.wrap && self.mode == ViewMode::Text && !self.from_edit
}
pub fn row(&self, index: usize) -> (usize, usize, usize) {
match self.rows.last() {
None => (index, 0, usize::MAX),
Some(&last) => self.rows.get(index).copied().unwrap_or(last),
}
}
pub fn row_of(&self, line: usize, col: usize) -> usize {
if self.rows.is_empty() {
return line;
}
self.rows.partition_point(|&(row_line, from, _)| (row_line, from) <= (line, col)).saturating_sub(1)
}
pub fn position_at(&self, row: usize, column: usize) -> (usize, usize) {
let (line, from, to) = self.row(row.min(self.total_lines.saturating_sub(1)));
let text: Vec<char> = self.line_text(line).chars().collect();
let (wanted, end) = (column + self.horizontal_offset, to.min(text.len()));
let (mut index, mut used) = (from.min(end), 0);
while index < end {
let width = crate::utils::char_display_width(text[index]);
if used + width > wanted {
break;
}
used += width;
index += 1;
}
(line, index)
}
pub fn rewrap(&mut self, width: usize) {
let (line, from, _) = self.row(self.scroll_offset);
let rows: Vec<(usize, usize, usize)> = (0..self.line_count()).flat_map(|index| wrap_ranges(&self.line_text(index), width).into_iter().map(move |(from, to)| (index, from, to))).collect();
self.rows = rows;
self.rows_width = width;
self.total_lines = self.rows.len().max(1);
self.horizontal_offset = 0;
self.scroll_offset = self.row_of(line, from).min(self.total_lines.saturating_sub(1));
}
pub fn unwrap_rows(&mut self) {
if self.rows.is_empty() {
return;
}
let (line, _, _) = self.row(self.scroll_offset);
self.rows.clear();
self.rows_width = 0;
self.total_lines = self.line_count();
self.scroll_offset = line;
}
pub fn next_mode(&self) -> ViewMode {
match self.mode {
ViewMode::Image => ViewMode::Text,
ViewMode::Text => ViewMode::Hex,
ViewMode::Hex if self.image.is_some() => ViewMode::Image,
ViewMode::Hex => ViewMode::Text,
}
}
}
pub fn wrap_ranges(text: &str, width: usize) -> Vec<(usize, usize)> {
let width = width.max(1);
let characters: Vec<char> = text.chars().collect();
let mut ranges = Vec::new();
let mut start = 0;
while start < characters.len() {
let (mut end, mut used, mut after_space) = (start, 0, None);
while end < characters.len() {
let cell = unicode_width::UnicodeWidthChar::width(characters[end]).unwrap_or(0);
if used + cell > width && end > start {
break;
}
used += cell;
if characters[end] == ' ' {
after_space = Some(end + 1);
}
end += 1;
}
if end < characters.len()
&& let Some(after_space) = after_space
{
end = after_space;
}
ranges.push((start, end));
start = end;
}
if ranges.is_empty() {
ranges.push((0, 0));
}
ranges
}