use {
super::{
detect_support::{
detect_kitty_graphics_protocol_display,
get_tmux_nest_count,
is_ssh,
},
terminal_esc::{
get_esc_seq,
get_tmux_header,
get_tmux_tail,
},
},
crate::{
display::{
W,
cell_size_in_pixels,
},
errors::ProgramError,
},
base64::{
self,
Engine,
engine::general_purpose::STANDARD as BASE64,
},
cli_log::*,
crokey::crossterm::{
QueueableCommand,
cursor,
style::Color,
},
flate2::{
Compression,
write::ZlibEncoder,
},
crate::image::zune_compat::{
DynamicImage,
RgbImage,
RgbaImage,
},
lru::LruCache,
rustc_hash::FxBuildHasher,
serde::Deserialize,
std::{
fs::File,
io::{
self,
Read,
Write,
},
num::NonZeroUsize,
path::{
Path,
PathBuf,
},
},
tempfile,
termimad::{
Area,
fill_bg,
},
};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransmissionMedium {
#[default]
TempFile,
Chunks,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KittyGraphicsDisplay {
None,
#[default]
Detect,
Direct,
Unicode,
}
#[derive(Debug, Clone)]
pub struct KittyImageRendererOptions {
pub display: KittyGraphicsDisplay,
pub transmission_medium: TransmissionMedium,
pub kept_temp_files: NonZeroUsize,
pub is_tmux: bool,
}
enum ImageData {
Rgb(RgbImage),
Rgba(RgbaImage),
}
impl From<&DynamicImage> for ImageData {
fn from(img: &DynamicImage) -> Self {
if let Some(rgba) = img.as_rgba8() {
debug!("using rgba");
Self::Rgba(rgba)
} else if let Some(rgb) = img.as_rgb8() {
debug!("using rgb");
Self::Rgb(rgb)
} else {
debug!("converting to rgb8");
Self::Rgb(img.to_rgb8())
}
}
}
impl ImageData {
fn kitty_format(&self) -> &'static str {
match self {
Self::Rgba(_) => "32",
Self::Rgb(_) => "24",
}
}
fn bytes(&self) -> Vec<u8> {
match self {
Self::Rgb(img) => img.as_raw(),
Self::Rgba(img) => img.as_raw(),
}
}
}
const CHUNK_SIZE: usize = 4096;
const PLACHOLDER: &str = "\u{10EEEE}";
#[rustfmt::skip]
const DIACRITICS: &[&str] = &[
"\u{0305}", "\u{030D}", "\u{030E}", "\u{0310}", "\u{0312}", "\u{033D}", "\u{033E}", "\u{033F}",
"\u{0346}", "\u{034A}", "\u{034B}", "\u{034C}", "\u{0350}", "\u{0351}", "\u{0352}", "\u{0357}",
"\u{035B}", "\u{0363}", "\u{0364}", "\u{0365}", "\u{0366}", "\u{0367}", "\u{0368}", "\u{0369}",
"\u{036A}", "\u{036B}", "\u{036C}", "\u{036D}", "\u{036E}", "\u{036F}", "\u{0483}", "\u{0484}",
"\u{0485}", "\u{0486}", "\u{0487}", "\u{0592}", "\u{0593}", "\u{0594}", "\u{0595}", "\u{0597}",
"\u{0598}", "\u{0599}", "\u{059C}", "\u{059D}", "\u{059E}", "\u{059F}", "\u{05A0}", "\u{05A1}",
"\u{05A8}", "\u{05A9}", "\u{05AB}", "\u{05AC}", "\u{05AF}", "\u{05C4}", "\u{0610}", "\u{0611}",
"\u{0612}", "\u{0613}", "\u{0614}", "\u{0615}", "\u{0616}", "\u{0617}", "\u{0657}", "\u{0658}",
"\u{0659}", "\u{065A}", "\u{065B}", "\u{065D}", "\u{065E}", "\u{06D6}", "\u{06D7}", "\u{06D8}",
"\u{06D9}", "\u{06DA}", "\u{06DB}", "\u{06DC}", "\u{06DF}", "\u{06E0}", "\u{06E1}", "\u{06E2}",
"\u{06E4}", "\u{06E7}", "\u{06E8}", "\u{06EB}", "\u{06EC}", "\u{0730}", "\u{0732}", "\u{0733}",
"\u{0735}", "\u{0736}", "\u{073A}", "\u{073D}", "\u{073F}", "\u{0740}", "\u{0741}", "\u{0743}",
"\u{0745}", "\u{0747}", "\u{0749}", "\u{074A}", "\u{07EB}", "\u{07EC}", "\u{07ED}", "\u{07EE}",
"\u{07EF}", "\u{07F0}", "\u{07F1}", "\u{07F3}", "\u{0816}", "\u{0817}", "\u{0818}", "\u{0819}",
"\u{081B}", "\u{081C}", "\u{081D}", "\u{081E}", "\u{081F}", "\u{0820}", "\u{0821}", "\u{0822}",
"\u{0823}", "\u{0825}", "\u{0826}", "\u{0827}", "\u{0829}", "\u{082A}", "\u{082B}", "\u{082C}",
"\u{082D}", "\u{0951}", "\u{0953}", "\u{0954}", "\u{0F82}", "\u{0F83}", "\u{0F86}", "\u{0F87}",
"\u{135D}", "\u{135E}", "\u{135F}", "\u{17DD}", "\u{193A}", "\u{1A17}", "\u{1A75}", "\u{1A76}",
"\u{1A77}", "\u{1A78}", "\u{1A79}", "\u{1A7A}", "\u{1A7B}", "\u{1A7C}", "\u{1B6B}", "\u{1B6D}",
"\u{1B6E}", "\u{1B6F}", "\u{1B70}", "\u{1B71}", "\u{1B72}", "\u{1B73}", "\u{1CD0}", "\u{1CD1}",
"\u{1CD2}", "\u{1CDA}", "\u{1CDB}", "\u{1CE0}", "\u{1DC0}", "\u{1DC1}", "\u{1DC3}", "\u{1DC4}",
"\u{1DC5}", "\u{1DC6}", "\u{1DC7}", "\u{1DC8}", "\u{1DC9}", "\u{1DCB}", "\u{1DCC}", "\u{1DD1}",
"\u{1DD2}", "\u{1DD3}", "\u{1DD4}", "\u{1DD5}", "\u{1DD6}", "\u{1DD7}", "\u{1DD8}", "\u{1DD9}",
"\u{1DDA}", "\u{1DDB}", "\u{1DDC}", "\u{1DDD}", "\u{1DDE}", "\u{1DDF}", "\u{1DE0}", "\u{1DE1}",
"\u{1DE2}", "\u{1DE3}", "\u{1DE4}", "\u{1DE5}", "\u{1DE6}", "\u{1DFE}", "\u{20D0}", "\u{20D1}",
"\u{20D4}", "\u{20D5}", "\u{20D6}", "\u{20D7}", "\u{20DB}", "\u{20DC}", "\u{20E1}", "\u{20E7}",
"\u{20E9}", "\u{20F0}", "\u{2CEF}", "\u{2CF0}", "\u{2CF1}", "\u{2DE0}", "\u{2DE1}", "\u{2DE2}",
"\u{2DE3}", "\u{2DE4}", "\u{2DE5}", "\u{2DE6}", "\u{2DE7}", "\u{2DE8}", "\u{2DE9}", "\u{2DEA}",
"\u{2DEB}", "\u{2DEC}", "\u{2DED}", "\u{2DEE}", "\u{2DEF}", "\u{2DF0}", "\u{2DF1}", "\u{2DF2}",
"\u{2DF3}", "\u{2DF4}", "\u{2DF5}", "\u{2DF6}", "\u{2DF7}", "\u{2DF8}", "\u{2DF9}", "\u{2DFA}",
"\u{2DFB}", "\u{2DFC}", "\u{2DFD}", "\u{2DFE}", "\u{2DFF}", "\u{A66F}", "\u{A67C}", "\u{A67D}",
"\u{A6F0}", "\u{A6F1}", "\u{A8E0}", "\u{A8E1}", "\u{A8E2}", "\u{A8E3}", "\u{A8E4}", "\u{A8E5}",
"\u{A8E6}", "\u{A8E7}", "\u{A8E8}", "\u{A8E9}", "\u{A8EA}", "\u{A8EB}", "\u{A8EC}", "\u{A8ED}",
"\u{A8EE}", "\u{A8EF}", "\u{A8F0}", "\u{A8F1}", "\u{AAB0}", "\u{AAB2}", "\u{AAB3}", "\u{AAB7}",
"\u{AAB8}", "\u{AABE}", "\u{AABF}", "\u{AAC1}", "\u{FE20}", "\u{FE21}", "\u{FE22}", "\u{FE23}",
"\u{FE24}", "\u{FE25}", "\u{FE26}", "\u{10A0F}", "\u{10A38}", "\u{1D185}", "\u{1D186}",
"\u{1D187}", "\u{1D188}", "\u{1D189}", "\u{1D1AA}", "\u{1D1AB}", "\u{1D1AC}", "\u{1D1AD}",
"\u{1D242}", "\u{1D243}", "\u{1D244}"
];
fn div_ceil(
a: u32,
b: u32,
) -> u32 {
a / b + u32::from(0 != a % b)
}
#[derive(Debug)]
pub struct KittyImageRenderer {
cell_width: u32,
cell_height: u32,
next_id: usize,
options: KittyImageRendererOptions,
temp_files: LruCache<String, PathBuf, FxBuildHasher>,
}
enum KittyImageData {
Png { path: PathBuf },
Image { data: ImageData },
}
struct KittyImage {
id: usize,
data: KittyImageData,
img_width: u32,
img_height: u32,
area: Area,
display: KittyGraphicsDisplay,
is_tmux: bool,
tmux_nest_count: u32,
}
impl KittyImage {
fn new(
src: &DynamicImage,
png_path: Option<PathBuf>,
available_area: &Area,
renderer: &mut KittyImageRenderer,
) -> Self {
let (img_width, img_height) = src.dimensions();
let area = renderer.rendering_area(img_width, img_height, available_area);
let data = if let Some(path) = png_path {
KittyImageData::Png { path }
} else {
KittyImageData::Image { data: src.into() }
};
let id = renderer.new_id();
let display = renderer.options.display;
let is_tmux = renderer.options.is_tmux;
let tmux_nest_count = if is_tmux { get_tmux_nest_count() } else { 0 };
Self {
id,
data,
img_width,
img_height,
area,
display,
is_tmux,
tmux_nest_count,
}
}
fn print_placeholder_grid(
&self,
w: &mut W,
) -> Result<(), ProgramError> {
let id_str = if self.id < 256 {
format!("\u{1b}[38;5;{}m", self.id)
} else {
format!(
"\u{1b}[38;2;{};{};{}m",
(self.id >> 16) & 0xff,
(self.id >> 8) & 0xff,
self.id & 0xff
)
};
let id_msb_str = if self.id >= (1 << 24) {
DIACRITICS[self.id >> 24]
} else {
""
};
for y in 0..(self.area.height).min(DIACRITICS.len() as u16) {
w.queue(cursor::MoveTo(self.area.left, self.area.top + y))?;
write!(w, "{}", &id_str)?;
if id_msb_str.is_empty() {
write!(w, "{}{}", PLACHOLDER, DIACRITICS[y as usize])?;
} else {
write!(
w,
"{}{}{}{}",
PLACHOLDER, DIACRITICS[y as usize], DIACRITICS[0], id_msb_str
)?;
}
write!(w, "{}", PLACHOLDER.repeat(self.area.width as usize - 1),)?;
write!(w, "\u{1b}[39m")?;
}
Ok(())
}
fn compress(data: &[u8]) -> Result<Vec<u8>, ProgramError> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).expect("Zlib encoder error");
Ok(encoder.finish().expect("Zlib encoder error"))
}
fn print_with_chunks(
&self,
w: &mut W,
) -> Result<(), ProgramError> {
let esc = get_esc_seq(self.tmux_nest_count);
let tmux_header = self
.is_tmux
.then_some(get_tmux_header(self.tmux_nest_count));
let tmux_tail = self.is_tmux.then_some(get_tmux_tail(self.tmux_nest_count));
let display_tag = match self.display {
KittyGraphicsDisplay::Unicode => "q=2,U=1,",
_ => "",
};
let mut png_buf = Vec::new();
let (bytes, compression_tag, format) = match &self.data {
KittyImageData::Png { path } => {
File::open(path)?.read_to_end(&mut png_buf)?;
(png_buf, "", "100")
}
KittyImageData::Image { data } => (
KittyImage::compress(&data.bytes())?,
"o=z,",
data.kitty_format(),
),
};
let encoded = BASE64.encode(bytes);
let mut pos = 0;
if self.display == KittyGraphicsDisplay::Direct {
w.queue(cursor::MoveTo(self.area.left, self.area.top))?;
}
if let Some(s) = &tmux_header {
write!(w, "{s}")?;
}
write!(
w,
"{}_Gq=2,a=t,f={},t=d,i={},s={},v={},{}",
&esc, format, self.id, self.img_width, self.img_height, compression_tag,
)?;
loop {
if pos != 0 {
if let Some(s) = &tmux_header {
write!(w, "{s}")?;
}
write!(w, "{}_Gq=2,", &esc)?;
}
if pos + CHUNK_SIZE < encoded.len() {
write!(w, "m=1;{}{}\\", &encoded[pos..pos + CHUNK_SIZE], &esc)?;
pos += CHUNK_SIZE;
if let Some(s) = &tmux_tail {
write!(w, "{s}")?;
}
} else {
write!(w, "m=0;{}{}\\", &encoded[pos..encoded.len()], &esc)?;
if let Some(s) = &tmux_tail {
write!(w, "{s}")?;
}
if let Some(s) = &tmux_header {
write!(w, "{s}")?;
}
write!(
w,
"{}_G{}a=p,i={},c={},r={}{}\\",
&esc, display_tag, self.id, self.area.width, self.area.height, &esc,
)?;
if let Some(s) = &tmux_tail {
write!(w, "{s}")?;
}
if self.display == KittyGraphicsDisplay::Unicode {
self.print_placeholder_grid(w)?;
}
break;
}
}
Ok(())
}
fn print_with_path(
&self,
w: &mut W,
path: &Path,
format: &str,
transmission: &str,
) -> Result<(), ProgramError> {
let esc = get_esc_seq(self.tmux_nest_count);
let tmux_header = self
.is_tmux
.then_some(get_tmux_header(self.tmux_nest_count));
let tmux_tail = self.is_tmux.then_some(get_tmux_tail(self.tmux_nest_count));
if self.display == KittyGraphicsDisplay::Direct {
w.queue(cursor::MoveTo(self.area.left, self.area.top))?;
}
let display_tag = match self.display {
KittyGraphicsDisplay::Unicode => "q=2,U=1,",
_ => "",
};
let path = path
.to_str()
.ok_or_else(|| io::Error::other("Path can't be converted to UTF8"))?;
let encoded_path = BASE64.encode(path);
if let KittyImageData::Image { data: _ } = self.data {
debug!("temp file written: {path:?}");
}
if let Some(s) = &tmux_header {
write!(w, "{s}")?;
}
write!(
w,
"{}_G{}a=T,f={},t={},i={},s={},v={},c={},r={};{}{}\\",
&esc,
display_tag,
format,
transmission,
self.id,
self.img_width,
self.img_height,
self.area.width,
self.area.height,
encoded_path,
&esc,
)?;
if let Some(s) = &tmux_tail {
write!(w, "{s}")?;
}
if self.display == KittyGraphicsDisplay::Unicode {
self.print_placeholder_grid(w)?;
}
Ok(())
}
pub fn print_with_png(
&self,
w: &mut W,
) -> Result<(), ProgramError> {
if let KittyImageData::Png { path } = &self.data {
self.print_with_path(w, path.as_path(), "100", "f")?;
}
Ok(())
}
pub fn print_with_temp_file(
&self,
w: &mut W,
temp_file: Option<File>, temp_file_path: &Path,
) -> Result<(), ProgramError> {
if let KittyImageData::Image { data } = &self.data {
if let Some(mut temp_file) = temp_file {
temp_file.write_all(&data.bytes())?;
temp_file.flush()?;
debug!("file len: {}", temp_file.metadata().unwrap().len());
}
self.print_with_path(w, temp_file_path, data.kitty_format(), "t")?;
}
Ok(())
}
}
impl KittyImageRenderer {
pub fn new(mut options: KittyImageRendererOptions) -> Option<Self> {
if options.display == KittyGraphicsDisplay::Detect {
options.display = detect_kitty_graphics_protocol_display();
}
if options.display == KittyGraphicsDisplay::None {
return None;
}
let hasher = FxBuildHasher;
let temp_files = LruCache::with_hasher(options.kept_temp_files, hasher);
let options = if is_ssh() {
KittyImageRendererOptions {
transmission_medium: TransmissionMedium::Chunks,
..options
}
} else {
options
};
cell_size_in_pixels()
.ok()
.map(|(cell_width, cell_height)| Self {
cell_width,
cell_height,
next_id: 1,
options,
temp_files,
})
}
pub fn delete_temp_files(&mut self) {
for (_, temp_file_path) in &self.temp_files {
debug!("removing temp file: {temp_file_path:?}");
if let Err(e) = std::fs::remove_file(temp_file_path) {
error!("failed to remove temp file: {e:?}");
}
}
}
fn new_id(&mut self) -> usize {
let new_id = self.next_id;
self.next_id += 1;
new_id
}
fn is_path_png(path: &Path) -> bool {
match path.extension() {
Some(ext) => ext == "png" || ext == "PNG",
None => false,
}
}
pub fn print(
&mut self,
w: &mut W,
src: &DynamicImage,
src_path: &Path,
area: &Area,
bg: Color,
) -> Result<usize, ProgramError> {
for y in area.top..area.top + area.height {
w.queue(cursor::MoveTo(area.left, y))?;
fill_bg(w, area.width as usize, bg)?;
}
let png_path = KittyImageRenderer::is_path_png(src_path).then_some(src_path.to_path_buf());
let is_png = png_path.is_some();
let img = KittyImage::new(src, png_path, area, self);
debug!(
"transmission medium: {:?}",
self.options.transmission_medium
);
w.flush()?;
match self.options.transmission_medium {
TransmissionMedium::TempFile if is_png => {
img.print_with_png(w)?;
}
TransmissionMedium::TempFile => {
let temp_file_key = format!("{:?}-{}x{}", src_path, img.img_width, img.img_height,);
let mut old_path = None;
if let Some(cached_path) = self.temp_files.pop(&temp_file_key) {
if cached_path.exists() {
old_path = Some(cached_path);
}
}
let temp_file_path = if let Some(temp_file_path) = old_path {
img.print_with_temp_file(w, None, &temp_file_path)?;
temp_file_path
} else {
let (temp_file, path) = tempfile::Builder::new()
.prefix("broot-tty-graphics-protocol-")
.tempfile()?
.keep()
.map_err(|_| io::Error::other("temp file can't be kept"))?;
img.print_with_temp_file(w, Some(temp_file), &path)?;
path
};
if let Some((_, old_path)) = self.temp_files.push(temp_file_key, temp_file_path) {
debug!("removing temp file: {:?}", &old_path);
if let Err(e) = std::fs::remove_file(&old_path) {
error!("failed to remove temp file: {e:?}");
}
}
}
TransmissionMedium::Chunks => img.print_with_chunks(w)?,
}
Ok(img.id)
}
fn rendering_area(
&self,
img_width: u32,
img_height: u32,
area: &Area,
) -> Area {
let area_cols: u32 = area.width.into();
let area_rows: u32 = area.height.into();
let rdim = self.rendering_dim(img_width, img_height, area_cols, area_rows);
Area::new(
area.left + ((area_cols - rdim.0) / 2) as u16,
area.top + ((area_rows - rdim.1) / 2) as u16,
rdim.0 as u16,
rdim.1 as u16,
)
}
fn rendering_dim(
&self,
img_width: u32,
img_height: u32,
area_cols: u32,
area_rows: u32,
) -> (u32, u32) {
let optimal_cols = div_ceil(img_width, self.cell_width);
let optimal_rows = div_ceil(img_height, self.cell_height);
debug!("area: {:?}", (area_cols, area_rows));
debug!("optimal: {:?}", (optimal_cols, optimal_rows));
if optimal_cols <= area_cols && optimal_rows <= area_rows {
(optimal_cols, optimal_rows)
} else if optimal_cols * area_rows > optimal_rows * area_cols {
debug!("constrained in width");
(area_cols, optimal_rows * area_cols / optimal_cols)
} else {
debug!("constrained in height");
(optimal_cols * area_rows / optimal_rows, area_rows)
}
}
}