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};
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
static HIGHLIGHTERS: OnceLock<std::collections::HashMap<&'static str, Highlighter<'static>>> = OnceLock::new();
pub type LineState = (ParseState, HighlightState);
fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
fn highlighter() -> &'static Highlighter<'static> {
let highlighters = HIGHLIGHTERS.get_or_init(|| {
let themes = THEME_SET.get_or_init(ThemeSet::load_defaults);
crate::options::Theme::ALL
.iter()
.map(|&theme| crate::display::palette_of(theme).syntax_theme)
.filter_map(|name| themes.themes.get(name).map(|theme| (name, Highlighter::new(theme))))
.collect()
});
highlighters.get(crate::display::palette().syntax_theme).unwrap_or_else(|| highlighters.values().next().expect("syntect ships its default themes"))
}
#[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)?;
if buffer[..bytes_read].contains(&0) {
return Ok(true);
}
match std::str::from_utf8(&buffer[..bytes_read]) {
Ok(_) => Ok(false),
Err(e) => Ok(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, format!("Not a regular file: {}", 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: "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 fn image_cost(path: &Path) -> Option<((u32, u32), u64)> {
let reader = image::ImageReader::open(path).ok()?.with_guessed_format().ok()?;
let (width, height) = reader.into_dimensions().ok()?;
Some(((width, height), u64::from(width) * u64::from(height) * 4))
}
pub fn decode_image(path: &Path) -> Option<(DynamicImage, String)> {
load_image(&std::fs::read(path).ok()?)
}
fn load_image(bytes: &[u8]) -> Option<(DynamicImage, String)> {
let format = image::guess_format(bytes).ok()?;
let image = image::load_from_memory_with_format(bytes, format).ok()?;
let name = format.extensions_str().first().map_or_else(|| format!("{format:?}"), |ext| ext.to_uppercase());
let label = format!("{} {}x{}", name, image.width(), image.height());
let image = if image.width().max(image.height()) > IMAGE_MAX_SIDE { image.thumbnail(IMAGE_MAX_SIDE, IMAGE_MAX_SIDE) } else { image };
Some((image, label))
}
pub fn image_size_for(image: &DynamicImage, width: usize, height: usize, fill: bool, zoom: u16) -> (usize, usize) {
image_size_on(image, width, height, fill, zoom, cell_aspect())
}
fn image_size_on(image: &DynamicImage, width: usize, height: usize, fill: bool, zoom: u16, cell: f64) -> (usize, usize) {
let full_height = height as f64 * cell * image.width() as f64 / image.height().max(1) as f64;
let base = if fill { width.max(full_height.ceil() as usize) } else { width.min(full_height.floor() as usize) };
let columns = base * zoom as usize / IMAGE_ZOOM_NORMAL as usize;
let overflow = IMAGE_MAX_OVERFLOW as f64;
let columns = columns.min(width * IMAGE_MAX_OVERFLOW).min((full_height * overflow).ceil() as usize).max(1);
let rows = aspect_rows_on(image, columns, cell);
if !fill && zoom <= IMAGE_ZOOM_NORMAL && rows > height { (columns, height.max(1)) } else { (columns, rows) }
}
fn aspect_rows_on(image: &DynamicImage, columns: usize, cell: f64) -> usize {
(image.height() as f64 * columns as f64 / image.width().max(1) as f64 / cell).round().max(1.0) as usize
}
fn cell_aspect() -> f64 {
crossterm::terminal::window_size()
.ok()
.filter(|size| size.rows > 0 && size.columns > 0 && size.width > 0 && size.height > 0)
.map(|size| (f64::from(size.height) / f64::from(size.rows)) / (f64::from(size.width) / f64::from(size.columns)))
.filter(|ratio| CELL_ASPECT_RANGE.contains(ratio))
.unwrap_or(CELL_ASPECT_FALLBACK)
}
fn coarse(channel: u8) -> u8 {
let band = 1u8 << IMAGE_COLOR_DROP_BITS;
(channel & !(band - 1)) | (band / 2)
}
pub fn image_to_ascii(image: &DynamicImage, columns: usize, rows: usize, backgrounds: bool) -> (Vec<String>, Vec<Vec<Color>>, Vec<Vec<Color>>) {
image_to_ascii_on(image, columns, rows, crate::display::light_background(), backgrounds)
}
fn image_to_ascii_on(image: &DynamicImage, columns: usize, rows: usize, light: bool, backgrounds: bool) -> (Vec<String>, Vec<Vec<Color>>, Vec<Vec<Color>>) {
let (columns, rows) = (columns.max(1) as u32, rows.max(1) as u32);
let small = image.resize_exact(columns, rows, FilterType::Triangle).to_rgba8();
let mut lines = Vec::with_capacity(rows as usize);
let mut inks = Vec::with_capacity(rows as usize);
let mut backs = Vec::with_capacity(rows as usize);
for row in small.rows() {
let mut line = String::with_capacity(columns as usize);
let mut ink_row = Vec::with_capacity(columns as usize);
let mut back_row = Vec::with_capacity(columns as usize);
for pixel in row {
let [red, green, blue, alpha] = pixel.0;
let luma = 0.299 * red as f64 + 0.587 * green as f64 + 0.114 * blue as f64;
let ink = if light { 255.0 - luma } else { luma };
let level = ink * alpha as f64 / 255.0 / 255.0;
line.push(IMAGE_RAMP[(level * (IMAGE_RAMP.len() - 1) as f64).round() as usize] as char);
let colour = [coarse(red), coarse(green), coarse(blue)];
if backgrounds {
let shift = |value: u8| {
let value = value as f64;
let lifted = if luma < 128.0 { value + (255.0 - value) * IMAGE_GLYPH_CONTRAST } else { value * (1.0 - IMAGE_GLYPH_CONTRAST) };
lifted.round() as u8
};
ink_row.push(Color::Rgb(shift(colour[0]), shift(colour[1]), shift(colour[2])));
back_row.push(Color::Rgb(colour[0], colour[1], colour[2]));
} else {
ink_row.push(Color::Rgb(colour[0], colour[1], colour[2]));
}
}
lines.push(line);
inks.push(ink_row);
if backgrounds {
backs.push(back_row);
}
}
(lines, inks, backs)
}
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!["Not a regular file".to_string()]);
}
let binary = is_binary_file(path).unwrap_or(false);
let unreadable = || Preview::Lines(vec!["Cannot read file".to_string()]);
let Ok(file) = File::open(path) else {
return unreadable();
};
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();
}
if binary {
return Preview::Bytes(buffer);
}
Preview::Lines(String::from_utf8_lossy(&buffer).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 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 length = self.line_text(line).chars().count();
(line, (from + column + self.horizontal_offset).min(to).min(length))
}
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
}
pub fn hex_line_count(bytes: &[u8]) -> usize {
bytes.len().div_ceil(HEX_BYTES_PER_LINE).max(1)
}
pub fn hex_line(offset: usize, bytes: &[u8]) -> String {
hex_row(offset, bytes, HEX_BYTES_PER_LINE, HEX_OFFSET_DIGITS)
}
pub fn hex_row(offset: usize, bytes: &[u8], per_line: usize, digits: usize) -> String {
let mut text = String::with_capacity(hex_row_width(per_line, digits));
if digits > 0 {
text.push_str(&format!("{offset:0digits$x} "));
}
for index in 0..per_line {
if digits > 0 && index == per_line / 2 {
text.push(' ');
}
match bytes.get(index) {
Some(byte) => text.push_str(&format!("{byte:02x} ")),
None => text.push_str(" "),
}
}
text.push_str(" |");
for byte in bytes {
text.push(if byte.is_ascii_graphic() || *byte == b' ' { *byte as char } else { '.' });
}
text.push('|');
text
}
pub const fn hex_row_width(per_line: usize, digits: usize) -> usize {
let offset = if digits > 0 { digits + 2 + 1 } else { 0 };
offset + per_line * 3 + 2 + per_line + 1
}
pub fn detect_syntax(path: &Path) -> String {
match path.extension().and_then(|s| s.to_str()) {
Some("rs") => "Rust".to_string(),
Some("py") => "Python".to_string(),
Some("js") | Some("jsx") => "JavaScript".to_string(),
Some("ts") | Some("tsx") => "TypeScript".to_string(),
Some("json") => "JSON".to_string(),
Some("toml") => "TOML".to_string(),
Some("yaml") | Some("yml") => "YAML".to_string(),
Some("md") => "Markdown".to_string(),
Some("sh") | Some("bash") => "Shell".to_string(),
Some("c") | Some("h") => "C".to_string(),
Some("cpp") | Some("hpp") => "C++".to_string(),
Some("html") => "HTML".to_string(),
Some("css") => "CSS".to_string(),
_ => "Plain Text".to_string(),
}
}
pub fn highlight_all(content: &[String], extension: &str) -> (Vec<Vec<Span<'static>>>, Vec<LineState>) {
let mut spans = Vec::new();
let mut states = Vec::new();
highlight_from(content, extension, 0, &mut spans, &mut states);
(spans, states)
}
pub fn highlight_from(content: &[String], extension: &str, from: usize, spans: &mut Vec<Vec<Span<'static>>>, states: &mut Vec<LineState>) {
highlight_through(content, extension, from, from, spans, states);
}
pub fn highlight_through(content: &[String], extension: &str, from: usize, through: usize, spans: &mut Vec<Vec<Span<'static>>>, states: &mut Vec<LineState>) {
let ps = syntax_set();
let hlr = highlighter();
let syntax = ps.find_syntax_by_extension(extension).unwrap_or_else(|| ps.find_syntax_plain_text());
let fresh = || (ParseState::new(syntax), HighlightState::new(hlr, ScopeStack::new()));
let mut start = from;
let delta = content.len() as isize - states.len() as isize;
if !states.is_empty() && states.len() == spans.len() {
start = from.min(states.len() - 1);
let at = (start + 1).min(states.len());
if delta > 0 {
let filler = states[start].clone();
for _ in 0..delta {
states.insert(at, filler.clone());
spans.insert(at, Vec::new());
}
} else {
for _ in 0..-delta {
if at < states.len() {
states.remove(at);
spans.remove(at);
}
}
}
}
let resumable = states.len() == content.len() && spans.len() == content.len();
if !resumable {
states.clear();
spans.clear();
states.reserve(content.len());
spans.reserve(content.len());
start = 0;
}
let (mut parse, mut hl) = if resumable { states[start].clone() } else { fresh() };
let min_converge = if resumable { (start + 1 + delta.max(0) as usize).max(through.saturating_add(1)) } else { usize::MAX };
let mut index = start;
while index < content.len() {
if index >= min_converge
&& let Some((cached_parse, cached_hl)) = states.get(index)
&& *cached_parse == parse
&& *cached_hl == hl
{
break;
}
let entering = (parse.clone(), hl.clone());
let line = &content[index];
let ops = parse.parse_line(line, ps).unwrap_or_default();
let line_spans: Vec<Span<'static>> = HighlightIterator::new(&mut hl, &ops, line, hlr)
.map(|(style, text)| Span::styled(text.to_string(), ratatui::style::Style::default().fg(syntect_to_ratatui_color(style.foreground))))
.collect();
if index < states.len() {
states[index] = entering;
spans[index] = line_spans;
} else {
states.push(entering);
spans.push(line_spans);
}
index += 1;
}
}
fn syntect_to_ratatui_color(color: syntect::highlighting::Color) -> Color {
Color::Rgb(color.r, color.g, color.b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_wrap_at_spaces_and_only_split_a_word_too_long_for_a_row() {
let pieces = |text: &str, width: usize| -> Vec<String> {
let characters: Vec<char> = text.chars().collect();
super::wrap_ranges(text, width).into_iter().map(|(from, to)| characters[from..to].iter().collect()).collect()
};
assert_eq!(pieces("the quick brown fox", 10), ["the quick ", "brown fox"]);
assert_eq!(pieces("abcdefghijkl xy", 5), ["abcde", "fghij", "kl xy"]);
assert_eq!(pieces("abcdefghijkl wxyz", 5), ["abcde", "fghij", "kl ", "wxyz"]);
assert_eq!(pieces("界界界", 4), ["界界", "界"]);
assert_eq!(pieces("界x", 1), ["界", "x"]);
assert_eq!(super::wrap_ranges("", 10), [(0, 0)]);
}
#[test]
fn hex_rows_measure_what_they_claim() {
let bytes: Vec<u8> = (0..=255u8).collect();
for (per_line, digits) in [(16, 8), (8, 4), (8, 0), (4, 0), (1, 8)] {
let full = hex_row(0, &bytes[..per_line], per_line, digits);
assert_eq!(full.chars().count(), hex_row_width(per_line, digits), "{per_line} bytes, {digits} digits: {full}");
let short = hex_row(0, &bytes[..1], per_line, digits);
assert_eq!(short.chars().count(), hex_row_width(per_line, digits) - (per_line - 1), "{short}");
}
assert_eq!(hex_row_width(HEX_BYTES_PER_LINE, HEX_OFFSET_DIGITS), crate::constants::HEX_LINE_WIDTH);
assert_eq!(hex_line(0, &bytes[..16]), hex_row(0, &bytes[..16], HEX_BYTES_PER_LINE, HEX_OFFSET_DIGITS));
}
#[test]
fn previews_binary_as_bytes_and_text_as_lines() {
let dir = std::env::temp_dir().join(format!("fm84-preview-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let binary = dir.join("binary");
std::fs::write(&binary, [0x7f, b'E', b'L', b'F', 0, 1, 2, 3]).unwrap();
let Preview::Bytes(bytes) = load_preview(&binary, 1024, 10) else {
panic!("a file with a null byte in it is a binary");
};
assert_eq!(bytes, [0x7f, b'E', b'L', b'F', 0, 1, 2, 3]);
let text = dir.join("text");
std::fs::write(&text, "one\ntwo\nthree\n").unwrap();
let Preview::Lines(lines) = load_preview(&text, 1024, 2) else {
panic!("text is still lines");
};
assert_eq!(lines, ["one", "two"]);
std::fs::remove_dir_all(&dir).unwrap();
}
fn sample(count: usize) -> Vec<String> {
let template = [
"pub fn item_{}(value: u32) -> Result<String, Error> {",
" let text = format!(\"item {} of the set\", value);",
" match value { 0 => Err(Error::Empty), _ => Ok(text) }",
"}",
];
(0..count).map(|i| template[i % template.len()].replace("{}", &i.to_string())).collect()
}
fn shape(spans: &[Vec<Span<'static>>]) -> Vec<Vec<(String, Option<Color>)>> {
spans.iter().map(|line| line.iter().map(|s| (s.content.to_string(), s.style.fg)).collect()).collect()
}
fn assert_matches_full(lines: &[String], spans: &[Vec<Span<'static>>], states: &[LineState], case: &str) {
let (expected, _) = highlight_all(lines, "rs");
assert_eq!(shape(spans), shape(&expected), "spans diverged after {case}");
assert_eq!(spans.len(), lines.len(), "span count wrong after {case}");
assert_eq!(states.len(), lines.len(), "state count wrong after {case}");
}
#[test]
fn incremental_matches_full_rehighlight() {
let mut lines = sample(200);
let (mut spans, mut states) = highlight_all(&lines, "rs");
assert_matches_full(&lines, &spans, &states, "initial load");
lines[50].push_str(" // trailing note");
highlight_from(&lines, "rs", 50, &mut spans, &mut states);
assert_matches_full(&lines, &spans, &states, "in-line edit");
lines.insert(10, "/* opening a block".to_string());
highlight_from(&lines, "rs", 10, &mut spans, &mut states);
assert_matches_full(&lines, &spans, &states, "block comment opened");
lines.insert(11, "closing it */".to_string());
highlight_from(&lines, "rs", 11, &mut spans, &mut states);
assert_matches_full(&lines, &spans, &states, "block comment closed");
lines.remove(11);
highlight_from(&lines, "rs", 11, &mut spans, &mut states);
assert_matches_full(&lines, &spans, &states, "line removed");
let last = lines.len() - 1;
lines[last].push_str("// end");
highlight_from(&lines, "rs", last, &mut spans, &mut states);
assert_matches_full(&lines, &spans, &states, "last-line edit");
lines.push("fn tail() {}".to_string());
highlight_from(&lines, "rs", lines.len() - 1, &mut spans, &mut states);
assert_matches_full(&lines, &spans, &states, "appended line");
}
}
#[cfg(test)]
mod image_tests {
use super::*;
use crate::constants::{IMAGE_MAX_DECODED, IMAGE_ZOOM_NORMAL, IMAGE_ZOOM_STEPS};
use image::{Rgba, RgbaImage};
fn image_size_for(image: &DynamicImage, width: usize, height: usize, fill: bool, zoom: u16) -> (usize, usize) {
image_size_on(image, width, height, fill, zoom, CELL_ASPECT_FALLBACK)
}
fn solid(width: u32, height: u32, pixel: [u8; 4]) -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::from_pixel(width, height, Rgba(pixel)))
}
#[test]
fn colours_share_bands_and_stay_in_range() {
let band = 1u8 << IMAGE_COLOR_DROP_BITS;
assert_eq!(coarse(0), band / 2);
assert_eq!(coarse(band - 1), band / 2);
assert_eq!(coarse(band), band + band / 2);
assert!((0..=255u8).all(|value| coarse(value).abs_diff(value) <= band / 2));
}
#[test]
fn ramp_ends_map_to_black_and_white() {
assert_eq!(image_to_ascii_on(&solid(4, 4, [0, 0, 0, 255]), 4, 2, false, false).0[0], " ");
assert_eq!(image_to_ascii_on(&solid(4, 4, [255, 255, 255, 255]), 4, 2, false, false).0[0], "@@@@");
}
#[test]
fn a_light_background_turns_the_ramp_round() {
assert_eq!(image_to_ascii_on(&solid(4, 4, [0, 0, 0, 255]), 4, 2, true, false).0[0], "@@@@");
assert_eq!(image_to_ascii_on(&solid(4, 4, [255, 255, 255, 255]), 4, 2, true, false).0[0], " ");
}
#[test]
fn transparent_is_left_blank() {
for light in [false, true] {
assert_eq!(image_to_ascii_on(&solid(4, 4, [255, 255, 255, 0]), 4, 2, light, false).0[0], " ");
assert_eq!(image_to_ascii_on(&solid(4, 4, [0, 0, 0, 0]), 4, 2, light, false).0[0], " ");
}
}
#[test]
fn backgrounds_carry_the_cell_colour() {
let red = [200u8, 40, 60, 255];
let want = Color::Rgb(coarse(200), coarse(40), coarse(60));
let (_, inks, backs) = image_to_ascii_on(&solid(4, 4, red), 4, 2, false, false);
assert!(backs.is_empty());
assert_eq!(inks[0][0], want);
let (_, inks, backs) = image_to_ascii_on(&solid(4, 4, red), 4, 2, false, true);
assert_eq!(backs.len(), inks.len());
assert_eq!(backs[0].len(), inks[0].len());
assert_eq!(backs[0][0], want);
assert_ne!(inks[0][0], backs[0][0]);
}
#[test]
fn a_glyph_lifts_off_a_dark_cell_and_sinks_into_a_bright_one() {
let level = |colour: Color| match colour {
Color::Rgb(red, green, blue) => red as u32 + green as u32 + blue as u32,
_ => unreachable!("image cells are always true colour"),
};
let (_, inks, backs) = image_to_ascii_on(&solid(2, 2, [20, 20, 20, 255]), 2, 1, false, true);
assert!(level(inks[0][0]) > level(backs[0][0]), "a dark cell needs a lighter glyph");
let (_, inks, backs) = image_to_ascii_on(&solid(2, 2, [240, 240, 240, 255]), 2, 1, false, true);
assert!(level(inks[0][0]) < level(backs[0][0]), "a bright cell needs a darker glyph");
}
#[test]
fn rows_are_halved_for_the_cell_aspect() {
let square = solid(100, 100, [128, 128, 128, 255]);
let (columns, rows) = image_size_for(&square, 40, 40, false, IMAGE_ZOOM_NORMAL);
assert_eq!((columns, rows), (40, 20));
let (lines, colors, _) = image_to_ascii_on(&square, columns, rows, false, false);
assert_eq!(lines.len(), 20);
assert!(lines.iter().all(|line| line.chars().count() == 40));
assert_eq!(colors.len(), 20);
assert!(colors.iter().flatten().all(|color| *color == Color::Rgb(coarse(128), coarse(128), coarse(128))));
assert_eq!(image_size_for(&solid(1000, 1, [0, 0, 0, 255]), 10, 10, false, IMAGE_ZOOM_NORMAL).1, 1);
}
#[test]
fn keeps_proportions_on_any_cell() {
for cell in [1.0, 1.6, 2.0, 2.4, 2.78, 3.5] {
for (width, height) in [(400u32, 300u32), (100, 100), (192, 108), (60, 90)] {
let image = solid(width, height, [128, 128, 128, 255]);
let (columns, rows) = image_size_on(&image, 120, 40, false, IMAGE_ZOOM_NORMAL, cell);
let drawn = columns as f64 / (rows as f64 * cell);
let wanted = f64::from(width) / f64::from(height);
assert!((drawn / wanted - 1.0).abs() < 0.05, "{width}x{height} on a {cell} cell drew {columns}x{rows}, shape {drawn:.3} against {wanted:.3}");
}
}
}
#[test]
fn cell_aspect_is_believable() {
let ratio = cell_aspect();
assert!(CELL_ASPECT_RANGE.contains(&ratio), "{ratio}");
}
#[test]
fn decodes_by_content_and_rejects_the_rest() {
let mut png = Vec::new();
solid(3, 2, [255, 0, 0, 255]).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let (image, label) = load_image(&png).unwrap();
assert_eq!((image.width(), image.height()), (3, 2));
assert_eq!(label, "PNG 3x2");
assert!(load_image(b"\0\x01\x02 not an image").is_none());
}
#[test]
fn fit_stays_inside_and_fill_covers() {
let square = solid(100, 100, [0, 0, 0, 255]);
assert_eq!(image_size_for(&square, 80, 20, false, IMAGE_ZOOM_NORMAL).0, 40);
assert_eq!(image_size_for(&square, 80, 20, true, IMAGE_ZOOM_NORMAL).0, 80);
assert_eq!(image_size_for(&square, 30, 20, false, IMAGE_ZOOM_NORMAL).0, 30);
assert_eq!(image_size_for(&square, 30, 20, true, IMAGE_ZOOM_NORMAL).0, 40);
for (image_width, image_height) in [(100, 100), (1024, 576), (1, 1024), (4, 1024), (1024, 1)] {
let image = solid(image_width, image_height, [0, 0, 0, 255]);
for (width, height) in [(80, 20), (30, 20), (200, 50), (7, 3)] {
let (columns, rows) = image_size_for(&image, width, height, false, IMAGE_ZOOM_NORMAL);
assert!(columns <= width && rows <= height, "{image_width}x{image_height} fit in {width}x{height}: {columns}x{rows}");
let drawn = image_to_ascii_on(&image, columns, rows, false, false).0;
assert_eq!(drawn.len(), rows);
}
}
for (width, height) in [(80, 20), (30, 20), (200, 50), (7, 3)] {
let (columns, rows) = image_size_for(&square, width, height, true, IMAGE_ZOOM_NORMAL);
assert!(columns >= width && rows >= height);
}
}
#[test]
fn extreme_aspects_stay_cheap_to_draw() {
const GREY: [u8; 4] = [128, 128, 128, 255];
let (width, height) = (200, 50);
let budget = width * height * IMAGE_MAX_OVERFLOW * IMAGE_MAX_OVERFLOW;
for (image_width, image_height) in [(1, 1024), (2, 1024), (4, 1024), (1024, 1), (1024, 2), (1024, 576)] {
let image = solid(image_width, image_height, GREY);
for fill in [false, true] {
for zoom in IMAGE_ZOOM_STEPS {
let (columns, rows) = image_size_for(&image, width, height, fill, zoom);
let (lines, colors, _) = image_to_ascii_on(&image, columns, rows, false, false);
let cells = columns * lines.len();
assert!(cells <= budget, "{image_width}x{image_height} fill={fill} zoom={zoom}: {columns}x{} is {cells} cells", lines.len());
assert_eq!(colors.len(), lines.len());
}
}
}
assert_eq!(image_size_for(&solid(1024, 576, GREY), width, height, true, IMAGE_ZOOM_NORMAL).0, width);
assert_eq!(image_size_for(&solid(1024, 576, GREY), width, height, false, IMAGE_ZOOM_NORMAL).0, 177);
}
#[test]
fn cost_comes_from_the_header_not_the_file_size() {
let dir = std::env::temp_dir().join(format!("fm84-image-cost-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("picture.png");
solid(600, 400, [1, 2, 3, 255]).save(&png).unwrap();
assert_eq!(image_cost(&png).unwrap(), ((600, 400), 600 * 400 * 4));
assert!(std::fs::metadata(&png).unwrap().len() * 100 < 600 * 400 * 4);
let text = dir.join("notes.txt");
std::fs::write(&text, "hello\n").unwrap();
assert!(image_cost(&text).is_none());
assert!(image_cost(&dir.join("absent.png")).is_none());
const { assert!(6000 * 4000 * 4 < IMAGE_MAX_DECODED) };
const { assert!(12000 * 12000 * 4 > IMAGE_MAX_DECODED) };
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn zoom_scales_the_fitted_size_and_stops_at_the_cap() {
let (width, height) = (200, 50);
let photo = solid(1024, 576, [128, 128, 128, 255]);
let fitted = image_size_for(&photo, width, height, false, IMAGE_ZOOM_NORMAL).0;
for zoom in IMAGE_ZOOM_STEPS {
let (columns, _) = image_size_for(&photo, width, height, false, zoom);
let wanted = fitted * zoom as usize / 100;
assert!(columns.abs_diff(wanted) <= 1, "zoom {zoom}%: {columns} columns, wanted about {wanted}");
}
assert!(image_size_for(&photo, width, height, false, 25).0 < fitted);
assert!(image_size_for(&photo, width, height, false, 400).1 > height);
assert_eq!(*IMAGE_ZOOM_STEPS.last().unwrap() as usize, IMAGE_MAX_OVERFLOW * 100);
assert!(IMAGE_ZOOM_STEPS.contains(&IMAGE_ZOOM_NORMAL));
assert!(IMAGE_ZOOM_STEPS.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn x_cycles_image_text_hex_and_text_hex() {
let dir = std::env::temp_dir().join(format!("fm84-view-modes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("picture.png");
solid(3, 2, [255, 0, 0, 255]).save(&png).unwrap();
let text = dir.join("notes.txt");
std::fs::write(&text, "hello\n").unwrap();
let mut state = load_file_content(&png, None).unwrap();
let mut seen = vec![state.mode];
for _ in 0..3 {
state.mode = state.next_mode();
seen.push(state.mode);
}
assert_eq!(seen, [ViewMode::Image, ViewMode::Text, ViewMode::Hex, ViewMode::Image]);
let mut state = load_file_content(&text, None).unwrap();
let mut seen = vec![state.mode];
for _ in 0..2 {
state.mode = state.next_mode();
seen.push(state.mode);
}
assert_eq!(seen, [ViewMode::Text, ViewMode::Hex, ViewMode::Text]);
std::fs::remove_dir_all(&dir).unwrap();
}
}