use std::cell::RefCell;
use std::collections::HashMap;
use anyhow::Result;
use image::DynamicImage;
use ratatui::layout::Rect;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use reqwest::Url;
const MARKER_START: &str = "[[image:";
const MARKER_END: &str = "]]";
const MAX_ROWS: u32 = 20;
pub fn marker(url: &str) -> String {
format!("{MARKER_START}{url}{MARKER_END}")
}
pub fn image_marker(line: &str) -> Option<&str> {
line.trim()
.strip_prefix(MARKER_START)?
.strip_suffix(MARKER_END)
.filter(|url| !url.is_empty())
}
pub fn image_urls(text: &str) -> Vec<String> {
text.lines()
.filter_map(image_marker)
.map(String::from)
.collect()
}
pub fn file_name(url: &str) -> String {
let Ok(parsed) = Url::parse(url) else {
return url.into();
};
if let Some((_, name)) = parsed.query_pairs().find(|(key, _)| key == "fileName") {
return name.into_owned();
}
parsed
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|segment| !segment.is_empty())
.map(percent_decode)
.unwrap_or_else(|| url.into())
}
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = bytes
.get(i + 1..i + 3)
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match (bytes[i], hex) {
(b'%', Some(byte)) => {
out.push(byte);
i += 3;
}
(byte, _) => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn extract_html_images(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(start) = find_ignore_case(rest, "<img") {
let Some(end) = rest[start..].find('>') else {
break;
};
let tag = &rest[start..start + end + 1];
out.push_str(&rest[..start]);
if let Some(src) = attribute(tag, "src") {
let src = src.replace("&", "&");
out.push_str(&format!("<p>{}</p>", marker(&src).replace('&', "&")));
}
rest = &rest[start + end + 1..];
}
out.push_str(rest);
out
}
fn find_ignore_case(haystack: &str, needle: &str) -> Option<usize> {
haystack
.to_ascii_lowercase()
.find(&needle.to_ascii_lowercase())
}
fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let mut from = 0;
while let Some(found) = lower[from..].find(name) {
let at = from + found;
from = at + name.len();
let before = lower[..at].chars().next_back();
if !before.is_some_and(char::is_whitespace) {
continue;
}
let value = tag[from..].trim_start().strip_prefix('=')?.trim_start();
return match value.chars().next()? {
quote @ ('"' | '\'') => value[1..].split(quote).next(),
_ => value
.split(|c: char| c.is_whitespace() || c == '>' || c == '/')
.next(),
};
}
None
}
pub fn markdown_images(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("![") {
let after = &rest[start + 2..];
let Some((url, consumed)) = after.find(']').and_then(|close| {
after[close + 1..].strip_prefix('(')?;
let url_start = close + 2;
let url_end = after[url_start..].find(')')?;
Some((
&after[url_start..url_start + url_end],
url_start + url_end + 1,
))
}) else {
out.push_str(&rest[..start + 2]);
rest = after;
continue;
};
let url = url.split_whitespace().next().unwrap_or_default();
out.push_str(&rest[..start]);
out.push('\n');
out.push_str(&marker(url));
out.push('\n');
rest = &after[consumed..];
}
out.push_str(rest);
out
}
pub enum ImageState {
Loading,
Ready {
protocol: Box<StatefulProtocol>,
cols: u16,
rows: u16,
},
Failed,
}
pub struct Images {
picker: Option<Picker>,
cache: RefCell<HashMap<String, ImageState>>,
drawn: RefCell<Vec<Rect>>,
previous: RefCell<Vec<Rect>>,
}
impl Images {
pub fn new(picker: Option<Picker>) -> Self {
Self {
picker: picker.filter(|picker| picker.protocol_type() != ProtocolType::Halfblocks),
cache: RefCell::new(HashMap::new()),
drawn: RefCell::default(),
previous: RefCell::default(),
}
}
pub fn enabled(&self) -> bool {
self.picker.is_some()
}
pub fn start_loading(&self, urls: Vec<String>) -> Vec<String> {
if !self.enabled() {
return Vec::new();
}
let mut cache = self.cache.borrow_mut();
let mut started = Vec::new();
for url in urls {
if !cache.contains_key(&url) {
cache.insert(url.clone(), ImageState::Loading);
started.push(url);
}
}
started
}
pub fn insert(&self, url: String, image: Result<DynamicImage>) {
let Some(picker) = &self.picker else {
return;
};
let state = match image {
Ok(image) => {
let font = picker.font_size();
let (cols, rows) = cell_size(
image.width(),
image.height(),
font.width.into(),
font.height.into(),
);
ImageState::Ready {
protocol: Box::new(picker.new_resize_protocol(image)),
cols,
rows,
}
}
Err(err) => {
tracing::warn!("failed to load image {url}: {err:#}");
ImageState::Failed
}
};
self.cache.borrow_mut().insert(url, state);
}
pub fn drawn_at(&self, area: Rect) {
self.drawn.borrow_mut().push(area);
}
pub fn stale_areas(&self) -> Vec<Rect> {
let drawn = self.drawn.take();
let previous = self.previous.replace(drawn);
if previous == *self.previous.borrow() {
Vec::new()
} else {
previous
}
}
pub fn with<R>(&self, url: &str, f: impl FnOnce(Option<&mut ImageState>) -> R) -> R {
f(self.cache.borrow_mut().get_mut(url))
}
}
fn cell_size(width: u32, height: u32, font_width: u32, font_height: u32) -> (u16, u16) {
let cols = width.div_ceil(font_width.max(1)).max(1);
let rows = height.div_ceil(font_height.max(1)).max(1);
let (cols, rows) = if rows > MAX_ROWS {
((cols * MAX_ROWS).div_ceil(rows).max(1), MAX_ROWS)
} else {
(cols, rows)
};
(
u16::try_from(cols).unwrap_or(u16::MAX),
u16::try_from(rows).unwrap_or(u16::MAX),
)
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str =
"https://dev.azure.com/contoso/p/_apis/wit/attachments/abc?fileName=image.png";
#[test]
fn marker_round_trips() {
assert_eq!(image_marker(&marker(URL)), Some(URL));
assert_eq!(image_marker(&format!(" {} ", marker(URL))), Some(URL));
}
#[test]
fn plain_text_is_not_a_marker() {
assert_eq!(image_marker("[[image:]]"), None);
assert_eq!(image_marker("see [[image:x]] here"), None);
assert_eq!(image_marker("text"), None);
}
#[test]
fn image_urls_collect_marker_lines() {
let text = format!("intro\n{}\nmiddle\n{}\n", marker("a"), marker("b"));
assert_eq!(image_urls(&text), ["a", "b"]);
}
#[test]
fn file_name_prefers_query_value() {
assert_eq!(file_name(URL), "image.png");
}
#[test]
fn file_name_falls_back_to_decoded_path() {
let url = "https://dev.azure.com/o/p/_apis/git/repositories/r/pullRequests/1/attachments/image%20%284%29.png";
assert_eq!(file_name(url), "image (4).png");
assert_eq!(file_name("not a url"), "not a url");
}
#[test]
fn html_images_become_marker_paragraphs() {
let html = r#"<div>Before<img src="https://x/a.png?x=1&fileName=a.png" alt=Image>After<IMG SRC='https://x/b.png'/></div>"#;
let out = extract_html_images(html);
assert_eq!(
out,
"<div>Before<p>[[image:https://x/a.png?x=1&fileName=a.png]]</p>After<p>[[image:https://x/b.png]]</p></div>"
);
}
#[test]
fn html_image_without_src_is_dropped() {
assert_eq!(extract_html_images("a<img alt=x>b"), "ab");
}
#[test]
fn data_src_is_not_mistaken_for_src() {
assert_eq!(
extract_html_images(r#"<img data-src="no" src="yes">"#),
"<p>[[image:yes]]</p>"
);
}
#[test]
fn markdown_images_get_their_own_line() {
assert_eq!(
markdown_images("See  here"),
"See \n[[image:https://x/image%20%284%29.png]]\n here"
);
}
#[test]
fn markdown_image_title_is_ignored() {
assert_eq!(
markdown_images(r#""#),
"\n[[image:https://x/a.png]]\n"
);
}
#[test]
fn markdown_without_images_is_unchanged() {
let text = "Not ![an image] and [a link](https://x)";
assert_eq!(markdown_images(text), text);
}
#[test]
fn cell_size_scales_down_tall_images() {
assert_eq!(cell_size(100, 40, 10, 20), (10, 2));
assert_eq!(cell_size(100, 800, 10, 20), (5, 20));
assert_eq!(cell_size(1, 1, 10, 20), (1, 1));
}
#[test]
fn images_are_disabled_without_graphics_protocol() {
let images = Images::new(Some(Picker::halfblocks()));
assert!(!images.enabled());
assert!(images.start_loading(vec!["a".into()]).is_empty());
}
#[test]
fn stale_areas_are_previous_placements_once_moved() {
let images = Images::new(None);
let area = Rect::new(0, 0, 4, 2);
let moved = Rect { y: 1, ..area };
images.drawn_at(area);
assert!(images.stale_areas().is_empty());
images.drawn_at(area);
assert!(images.stale_areas().is_empty());
images.drawn_at(moved);
assert_eq!(images.stale_areas(), [area]);
assert_eq!(images.stale_areas(), [moved]);
assert!(images.stale_areas().is_empty());
}
}