pub mod cache;
pub mod detect;
pub mod fetch;
use std::io::Cursor;
use image::ImageReader;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use tokio::sync::mpsc;
use tracing::{error, warn};
use crate::config::ImagePreviewConfig;
#[derive(Default)]
pub enum PreviewStatus {
#[default]
Hidden,
Loading { url: String },
Ready {
url: String,
title: Option<String>,
image: Box<StatefulProtocol>,
raw_png: Vec<u8>,
width: u16,
height: u16,
direct_written: bool,
},
Error { url: String, message: String },
}
pub enum ImagePreviewEvent {
Ready {
url: String,
title: Option<String>,
image: Box<StatefulProtocol>,
raw_png: Vec<u8>,
width: u16,
height: u16,
},
Error { url: String, message: String },
}
pub fn spawn_preview(
url: &str,
config: &ImagePreviewConfig,
picker: &Picker,
http_client: &reqwest::Client,
tx: mpsc::Sender<ImagePreviewEvent>,
term_size: (u16, u16),
) {
let config = config.clone();
let picker = picker.clone();
let client = http_client.clone();
let url = url.to_owned();
tokio::spawn(async move {
let fetch_result = fetch_image_data(&url, &config, &client).await;
let event = match fetch_result {
Ok((data, title)) => {
let decode_result = tokio::task::spawn_blocking(move || {
decode_and_encode(&data, &config, &picker, term_size)
})
.await;
match decode_result {
Ok(Ok((protocol, png_buf, width, height))) => ImagePreviewEvent::Ready {
url,
title,
image: Box::new(protocol),
raw_png: png_buf,
width,
height,
},
Ok(Err(e)) => {
error!(url = %url, error = %e, "image preview decode failed");
ImagePreviewEvent::Error {
url,
message: e.to_string(),
}
}
Err(e) => {
error!(url = %url, error = %e, "image preview task panicked");
ImagePreviewEvent::Error {
url,
message: e.to_string(),
}
}
}
}
Err(e) => {
error!(url = %url, error = %e, "image preview fetch failed");
ImagePreviewEvent::Error {
url,
message: e.to_string(),
}
}
};
if tx.send(event).await.is_err() {
warn!("image preview channel closed before result could be sent");
}
});
}
async fn fetch_image_data(
url: &str,
config: &ImagePreviewConfig,
client: &reqwest::Client,
) -> color_eyre::eyre::Result<(Vec<u8>, Option<String>)> {
if let Some(cached_path) = cache::is_cached(url) {
let data = tokio::fs::read(&cached_path).await?;
let title = detect::classify_url(url).and_then(|c| c.title);
return Ok((data, title));
}
let fetch_config = fetch::FetchConfig {
timeout_secs: config.fetch_timeout,
max_file_size: config.max_file_size,
};
let result = fetch::fetch_image(url, &fetch_config, client).await?;
if !cache::validate_magic_bytes(&result.data) {
return Err(color_eyre::eyre::eyre!(
"downloaded data does not appear to be a valid image"
));
}
if let Err(e) = cache::store(url, &result.data, &result.content_type) {
warn!(url, error = %e, "failed to cache image");
}
let title = detect::classify_url(url).and_then(|c| c.title);
Ok((result.data, title))
}
type DecodeResult = (StatefulProtocol, Vec<u8>, u16, u16);
fn decode_and_encode(
data: &[u8],
config: &ImagePreviewConfig,
picker: &Picker,
term_size: (u16, u16),
) -> color_eyre::eyre::Result<DecodeResult> {
let dyn_img = ImageReader::new(Cursor::new(data))
.with_guessed_format()?
.decode()?;
let font_size = picker.font_size();
tracing::debug!(
img_w = dyn_img.width(),
img_h = dyn_img.height(),
term_cols = term_size.0,
term_rows = term_size.1,
font_w = font_size.0,
font_h = font_size.1,
protocol = ?picker.protocol_type(),
"image decode: input dimensions"
);
let (width, height) = calculate_display_size(config, term_size, &dyn_img, font_size);
tracing::debug!(
popup_w = width,
popup_h = height,
inner_w = width.saturating_sub(2),
inner_h = height.saturating_sub(2),
"image decode: popup dimensions"
);
let mut png_buf: Vec<u8> = Vec::new();
dyn_img.write_to(&mut Cursor::new(&mut png_buf), image::ImageFormat::Png)?;
let protocol = picker.new_resize_protocol(dyn_img);
Ok((protocol, png_buf, width, height))
}
fn calculate_display_size(
config: &ImagePreviewConfig,
term_size: (u16, u16),
img: &image::DynamicImage,
font_size: (u16, u16),
) -> (u16, u16) {
let max_cols = if config.max_width > 0 {
u16::try_from(config.max_width).unwrap_or(u16::MAX)
} else {
term_size.0 * 3 / 4
};
let max_rows = if config.max_height > 0 {
u16::try_from(config.max_height).unwrap_or(u16::MAX)
} else {
term_size.1 * 3 / 4
};
let inner_cols = max_cols.saturating_sub(2).max(1);
let inner_rows = max_rows.saturating_sub(2).max(1);
let img_w = img.width();
let img_h = img.height();
if img_w == 0 || img_h == 0 {
return (max_cols.min(10), max_rows.min(5));
}
let fw = f64::from(font_size.0.max(1));
let fh = f64::from(font_size.1.max(1));
let img_cells_w = (f64::from(img_w) / fw).ceil();
let img_cells_h = (f64::from(img_h) / fh).ceil();
let fitted_cols = img_cells_w.min(f64::from(inner_cols));
let fitted_rows = img_cells_h.min(f64::from(inner_rows));
let scale_x = fitted_cols / img_cells_w;
let scale_y = fitted_rows / img_cells_h;
let scale = scale_x.min(scale_y);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "dimensions are small positive values; truncation is intentional"
)]
let final_cols = (img_cells_w * scale).round().max(1.0) as u16;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "dimensions are small positive values; truncation is intentional"
)]
let final_rows = (img_cells_h * scale).round().max(1.0) as u16;
(final_cols + 2, final_rows + 2)
}