use std::cell::RefCell;
use std::io::Read;
use std::rc::Rc;
use crate::base::{Point, Rect, Rgba};
use crate::gfx::mosaic::{self, MosaicGrid, MosaicMode};
use crate::gfx::{decode_image, probe_dimensions};
use crate::render::md::ImageBlock;
use crate::render::rich::{RichLine, Span};
use crate::render::Style;
use crate::theme::TokenSet;
use crate::ui::StyledCanvas;
use super::Row;
const MAX_IMAGE_ROWS: i32 = 200;
const PROBE_STEPS: [usize; 2] = [64 * 1024, 2 * 1024 * 1024];
#[derive(Clone)]
pub(crate) struct MdImageSlice {
pub(crate) handle: Rc<MdImageHandle>,
pub(crate) row: i32,
}
pub(crate) struct MdImageHandle {
src: String,
alt: String,
cols: i32,
rows: i32,
state: RefCell<ImgState>,
}
enum ImgState {
Pending,
Ready(Rc<MosaicGrid>),
Failed(String),
}
pub(crate) fn push_image_rows(out: &mut Vec<Row>, image: &ImageBlock, width: i32, t: &TokenSet) {
let faint = Style::new().fg(t.text_faint);
match probe_file(&image.src) {
Ok((px_w, px_h)) => {
let width = width.max(1);
let mut cols = (px_w as i32).clamp(1, width);
let mut rows = ((cols as f64) * (px_h as f64) / (px_w as f64) / 2.0).round() as i32;
rows = rows.max(1);
if rows > MAX_IMAGE_ROWS {
rows = MAX_IMAGE_ROWS;
cols = ((rows as f64) * 2.0 * (px_w as f64) / (px_h as f64)).round() as i32;
cols = cols.clamp(1, width);
}
let handle = Rc::new(MdImageHandle {
src: image.src.clone(),
alt: image.alt.clone(),
cols,
rows,
state: RefCell::new(ImgState::Pending),
});
for row in 0..rows {
out.push(Row {
line: RichLine::new(),
indent: 0,
ground: None,
quote: false,
rule: false,
image: Some(MdImageSlice {
handle: handle.clone(),
row,
}),
});
}
}
Err(reason) => {
out.push(Row::plain(RichLine::from_spans(vec![Span::new(
format!("⌧ image unavailable: {} ({reason})", image.src),
faint,
)])));
}
}
if !image.alt.is_empty() {
out.push(Row::plain(RichLine::from_spans(vec![Span::new(
image.alt.clone(),
faint,
)])));
}
}
pub(crate) fn draw_image_row(
canvas: &mut dyn StyledCanvas,
rect: Rect,
y: i32,
t: &TokenSet,
indent: i32,
slice: &MdImageSlice,
) {
let handle = &slice.handle;
{
let mut state = handle.state.borrow_mut();
if matches!(*state, ImgState::Pending) {
*state = match load_mosaic(&handle.src, handle.cols, handle.rows) {
Ok(grid) => ImgState::Ready(grid),
Err(reason) => ImgState::Failed(reason),
};
}
}
let state = handle.state.borrow();
match &*state {
ImgState::Ready(grid) => {
let x0 = rect.x + indent;
let cols = (grid.cols() as i32).min(rect.right() - x0);
for col in 0..cols.max(0) {
if let Some(cell) = grid.get(col as u32, slice.row as u32) {
canvas.put(Point::new(x0 + col, y), cell.ch, cell.fg, cell.bg);
}
}
}
ImgState::Failed(reason) => {
if slice.row == 0 {
let label = if handle.alt.is_empty() {
&handle.src
} else {
&handle.alt
};
canvas.print(
Point::new(rect.x + indent, y),
&format!("⌧ {label}: {reason}"),
t.text_faint,
Rgba::TRANSPARENT,
);
}
}
ImgState::Pending => unreachable!("resolved above"),
}
}
type ProbeEntry = (String, u64, Option<(u32, u32)>);
type MosaicKey = (String, u64, i32, i32);
thread_local! {
static PROBE_CACHE: RefCell<Vec<ProbeEntry>> = const { RefCell::new(Vec::new()) };
static MOSAIC_CACHE: RefCell<Vec<(MosaicKey, Rc<MosaicGrid>)>> =
const { RefCell::new(Vec::new()) };
static DECODE_COUNT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
const PROBE_CACHE_CAP: usize = 128;
const MOSAIC_CACHE_CAP: usize = 32;
#[cfg(test)]
pub(crate) fn decode_count() -> u64 {
DECODE_COUNT.with(|c| c.get())
}
fn file_signature(path: &str) -> Result<u64, String> {
let meta = std::fs::metadata(path).map_err(|e| format!("unreadable: {e}"))?;
let mtime = meta
.modified()
.ok()
.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let mut sig = meta.len() ^ mtime.rotate_left(17);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
sig ^= meta.ino().rotate_left(31) ^ meta.dev().rotate_left(47);
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
sig ^= meta.creation_time().rotate_left(31);
}
Ok(sig)
}
fn probe_file(path: &str) -> Result<(u32, u32), String> {
let sig = file_signature(path)?;
let cached = PROBE_CACHE.with(|c| {
let cache = c.borrow();
cache
.iter()
.find(|(p, s, _)| p == path && *s == sig)
.map(|(_, _, dims)| *dims)
});
if let Some(dims) = cached {
return dims.ok_or_else(|| "not a decodable image (PNG/JPEG headers only)".to_string());
}
let dims = probe_file_uncached(path);
PROBE_CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.retain(|(p, _, _)| p != path);
cache.push((path.to_string(), sig, dims));
if cache.len() > PROBE_CACHE_CAP {
cache.remove(0);
}
});
dims.ok_or_else(|| "not a decodable image (PNG/JPEG headers only)".to_string())
}
fn probe_file_uncached(path: &str) -> Option<(u32, u32)> {
let mut file = std::fs::File::open(path).ok()?;
let mut buf: Vec<u8> = Vec::new();
for limit in PROBE_STEPS {
if buf.len() < limit {
let mut take = (&mut file).take((limit - buf.len()) as u64);
if take.read_to_end(&mut buf).is_err() {
return None;
}
}
if let Some(dims) = probe_dimensions(&buf) {
return Some(dims);
}
if buf.len() < limit {
return None; }
}
if file.read_to_end(&mut buf).is_err() {
return None;
}
probe_dimensions(&buf)
}
fn load_mosaic(path: &str, cols: i32, rows: i32) -> Result<Rc<MosaicGrid>, String> {
let sig = file_signature(path)?;
let key = (path.to_string(), sig, cols, rows);
let hit = MOSAIC_CACHE.with(|c| {
let mut cache = c.borrow_mut();
if let Some(pos) = cache.iter().position(|(k, _)| *k == key) {
let entry = cache.remove(pos);
let grid = entry.1.clone();
cache.push(entry); Some(grid)
} else {
None
}
});
if let Some(grid) = hit {
return Ok(grid);
}
let bytes = std::fs::read(path).map_err(|e| format!("unreadable: {e}"))?;
let bitmap = decode_image(&bytes).map_err(|e| format!("decode failed: {e}"))?;
DECODE_COUNT.with(|c| c.set(c.get() + 1));
let grid = Rc::new(mosaic::render(
&bitmap,
cols.max(1) as u32,
rows.max(1) as u32,
MosaicMode::HalfBlock,
));
MOSAIC_CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.push((key, grid.clone()));
if cache.len() > MOSAIC_CACHE_CAP {
cache.remove(0);
}
});
Ok(grid)
}
#[cfg(test)]
#[path = "markdown_image_tests.rs"]
mod tests;