1use {
2 super::{
3 detect_support::{
4 detect_kitty_graphics_protocol_display,
5 get_tmux_nest_count,
6 is_ssh,
7 },
8 terminal_esc::{
9 get_esc_seq,
10 get_tmux_header,
11 get_tmux_tail,
12 },
13 },
14 crate::{
15 display::{
16 W,
17 cell_size_in_pixels,
18 },
19 errors::ProgramError,
20 },
21 base64::{
22 self,
23 Engine,
24 engine::general_purpose::STANDARD as BASE64,
25 },
26 cli_log::*,
27 crokey::crossterm::{
28 QueueableCommand,
29 cursor,
30 style::Color,
31 },
32 flate2::{
33 Compression,
34 write::ZlibEncoder,
35 },
36 crate::image::zune_compat::{
37 DynamicImage,
38 RgbImage,
39 RgbaImage,
40 },
41 lru::LruCache,
42 rustc_hash::FxBuildHasher,
43 serde::Deserialize,
44 std::{
45 fs::File,
46 io::{
47 self,
48 Read,
49 Write,
50 },
51 num::NonZeroUsize,
52 path::{
53 Path,
54 PathBuf,
55 },
56 },
57 tempfile,
58 termimad::{
59 Area,
60 fill_bg,
61 },
62};
63
64#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum TransmissionMedium {
74 #[default]
79 TempFile,
80 Chunks,
85}
86
87#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum KittyGraphicsDisplay {
91 None,
93 #[default]
95 Detect,
96 Direct,
98 Unicode,
100}
101
102#[derive(Debug, Clone)]
103pub struct KittyImageRendererOptions {
104 pub display: KittyGraphicsDisplay,
105 pub transmission_medium: TransmissionMedium,
106 pub kept_temp_files: NonZeroUsize,
107 pub is_tmux: bool,
108}
109
110enum ImageData {
111 Rgb(RgbImage),
112 Rgba(RgbaImage),
113}
114impl From<&DynamicImage> for ImageData {
115 fn from(img: &DynamicImage) -> Self {
116 if let Some(rgba) = img.as_rgba8() {
117 debug!("using rgba");
118 Self::Rgba(rgba)
119 } else if let Some(rgb) = img.as_rgb8() {
120 debug!("using rgb");
121 Self::Rgb(rgb)
122 } else {
123 debug!("converting to rgb8");
124 Self::Rgb(img.to_rgb8())
125 }
126 }
127}
128impl ImageData {
129 fn kitty_format(&self) -> &'static str {
130 match self {
131 Self::Rgba(_) => "32",
132 Self::Rgb(_) => "24",
133 }
134 }
135 fn bytes(&self) -> Vec<u8> {
136 match self {
137 Self::Rgb(img) => img.as_raw(),
138 Self::Rgba(img) => img.as_raw(),
139 }
140 }
141}
142
143const CHUNK_SIZE: usize = 4096;
146
147const PLACHOLDER: &str = "\u{10EEEE}";
149#[rustfmt::skip]
151const DIACRITICS: &[&str] = &[
152 "\u{0305}", "\u{030D}", "\u{030E}", "\u{0310}", "\u{0312}", "\u{033D}", "\u{033E}", "\u{033F}",
153 "\u{0346}", "\u{034A}", "\u{034B}", "\u{034C}", "\u{0350}", "\u{0351}", "\u{0352}", "\u{0357}",
154 "\u{035B}", "\u{0363}", "\u{0364}", "\u{0365}", "\u{0366}", "\u{0367}", "\u{0368}", "\u{0369}",
155 "\u{036A}", "\u{036B}", "\u{036C}", "\u{036D}", "\u{036E}", "\u{036F}", "\u{0483}", "\u{0484}",
156 "\u{0485}", "\u{0486}", "\u{0487}", "\u{0592}", "\u{0593}", "\u{0594}", "\u{0595}", "\u{0597}",
157 "\u{0598}", "\u{0599}", "\u{059C}", "\u{059D}", "\u{059E}", "\u{059F}", "\u{05A0}", "\u{05A1}",
158 "\u{05A8}", "\u{05A9}", "\u{05AB}", "\u{05AC}", "\u{05AF}", "\u{05C4}", "\u{0610}", "\u{0611}",
159 "\u{0612}", "\u{0613}", "\u{0614}", "\u{0615}", "\u{0616}", "\u{0617}", "\u{0657}", "\u{0658}",
160 "\u{0659}", "\u{065A}", "\u{065B}", "\u{065D}", "\u{065E}", "\u{06D6}", "\u{06D7}", "\u{06D8}",
161 "\u{06D9}", "\u{06DA}", "\u{06DB}", "\u{06DC}", "\u{06DF}", "\u{06E0}", "\u{06E1}", "\u{06E2}",
162 "\u{06E4}", "\u{06E7}", "\u{06E8}", "\u{06EB}", "\u{06EC}", "\u{0730}", "\u{0732}", "\u{0733}",
163 "\u{0735}", "\u{0736}", "\u{073A}", "\u{073D}", "\u{073F}", "\u{0740}", "\u{0741}", "\u{0743}",
164 "\u{0745}", "\u{0747}", "\u{0749}", "\u{074A}", "\u{07EB}", "\u{07EC}", "\u{07ED}", "\u{07EE}",
165 "\u{07EF}", "\u{07F0}", "\u{07F1}", "\u{07F3}", "\u{0816}", "\u{0817}", "\u{0818}", "\u{0819}",
166 "\u{081B}", "\u{081C}", "\u{081D}", "\u{081E}", "\u{081F}", "\u{0820}", "\u{0821}", "\u{0822}",
167 "\u{0823}", "\u{0825}", "\u{0826}", "\u{0827}", "\u{0829}", "\u{082A}", "\u{082B}", "\u{082C}",
168 "\u{082D}", "\u{0951}", "\u{0953}", "\u{0954}", "\u{0F82}", "\u{0F83}", "\u{0F86}", "\u{0F87}",
169 "\u{135D}", "\u{135E}", "\u{135F}", "\u{17DD}", "\u{193A}", "\u{1A17}", "\u{1A75}", "\u{1A76}",
170 "\u{1A77}", "\u{1A78}", "\u{1A79}", "\u{1A7A}", "\u{1A7B}", "\u{1A7C}", "\u{1B6B}", "\u{1B6D}",
171 "\u{1B6E}", "\u{1B6F}", "\u{1B70}", "\u{1B71}", "\u{1B72}", "\u{1B73}", "\u{1CD0}", "\u{1CD1}",
172 "\u{1CD2}", "\u{1CDA}", "\u{1CDB}", "\u{1CE0}", "\u{1DC0}", "\u{1DC1}", "\u{1DC3}", "\u{1DC4}",
173 "\u{1DC5}", "\u{1DC6}", "\u{1DC7}", "\u{1DC8}", "\u{1DC9}", "\u{1DCB}", "\u{1DCC}", "\u{1DD1}",
174 "\u{1DD2}", "\u{1DD3}", "\u{1DD4}", "\u{1DD5}", "\u{1DD6}", "\u{1DD7}", "\u{1DD8}", "\u{1DD9}",
175 "\u{1DDA}", "\u{1DDB}", "\u{1DDC}", "\u{1DDD}", "\u{1DDE}", "\u{1DDF}", "\u{1DE0}", "\u{1DE1}",
176 "\u{1DE2}", "\u{1DE3}", "\u{1DE4}", "\u{1DE5}", "\u{1DE6}", "\u{1DFE}", "\u{20D0}", "\u{20D1}",
177 "\u{20D4}", "\u{20D5}", "\u{20D6}", "\u{20D7}", "\u{20DB}", "\u{20DC}", "\u{20E1}", "\u{20E7}",
178 "\u{20E9}", "\u{20F0}", "\u{2CEF}", "\u{2CF0}", "\u{2CF1}", "\u{2DE0}", "\u{2DE1}", "\u{2DE2}",
179 "\u{2DE3}", "\u{2DE4}", "\u{2DE5}", "\u{2DE6}", "\u{2DE7}", "\u{2DE8}", "\u{2DE9}", "\u{2DEA}",
180 "\u{2DEB}", "\u{2DEC}", "\u{2DED}", "\u{2DEE}", "\u{2DEF}", "\u{2DF0}", "\u{2DF1}", "\u{2DF2}",
181 "\u{2DF3}", "\u{2DF4}", "\u{2DF5}", "\u{2DF6}", "\u{2DF7}", "\u{2DF8}", "\u{2DF9}", "\u{2DFA}",
182 "\u{2DFB}", "\u{2DFC}", "\u{2DFD}", "\u{2DFE}", "\u{2DFF}", "\u{A66F}", "\u{A67C}", "\u{A67D}",
183 "\u{A6F0}", "\u{A6F1}", "\u{A8E0}", "\u{A8E1}", "\u{A8E2}", "\u{A8E3}", "\u{A8E4}", "\u{A8E5}",
184 "\u{A8E6}", "\u{A8E7}", "\u{A8E8}", "\u{A8E9}", "\u{A8EA}", "\u{A8EB}", "\u{A8EC}", "\u{A8ED}",
185 "\u{A8EE}", "\u{A8EF}", "\u{A8F0}", "\u{A8F1}", "\u{AAB0}", "\u{AAB2}", "\u{AAB3}", "\u{AAB7}",
186 "\u{AAB8}", "\u{AABE}", "\u{AABF}", "\u{AAC1}", "\u{FE20}", "\u{FE21}", "\u{FE22}", "\u{FE23}",
187 "\u{FE24}", "\u{FE25}", "\u{FE26}", "\u{10A0F}", "\u{10A38}", "\u{1D185}", "\u{1D186}",
188 "\u{1D187}", "\u{1D188}", "\u{1D189}", "\u{1D1AA}", "\u{1D1AB}", "\u{1D1AC}", "\u{1D1AD}",
189 "\u{1D242}", "\u{1D243}", "\u{1D244}"
190];
191
192fn div_ceil(
193 a: u32,
194 b: u32,
195) -> u32 {
196 a / b + (0 != a % b) as u32
197}
198
199#[derive(Debug)]
202pub struct KittyImageRenderer {
203 cell_width: u32,
204 cell_height: u32,
205 next_id: usize,
206 options: KittyImageRendererOptions,
207 temp_files: LruCache<String, PathBuf, FxBuildHasher>,
210}
211
212enum KittyImageData {
213 Png { path: PathBuf },
214 Image { data: ImageData },
215}
216
217struct KittyImage {
219 id: usize,
220 data: KittyImageData,
221 img_width: u32,
222 img_height: u32,
223 area: Area,
224 display: KittyGraphicsDisplay,
225 is_tmux: bool,
226 tmux_nest_count: u32,
227}
228impl KittyImage {
229 fn new(
230 src: &DynamicImage,
231 png_path: Option<PathBuf>,
232 available_area: &Area,
233 renderer: &mut KittyImageRenderer,
234 ) -> Self {
235 let (img_width, img_height) = src.dimensions();
236 let area = renderer.rendering_area(img_width, img_height, available_area);
237 let data = if let Some(path) = png_path {
238 KittyImageData::Png { path }
239 } else {
240 KittyImageData::Image { data: src.into() }
241 };
242 let id = renderer.new_id();
243 let display = renderer.options.display;
244 let is_tmux = renderer.options.is_tmux;
245 let tmux_nest_count = if is_tmux { get_tmux_nest_count() } else { 0 };
246 Self {
247 id,
248 data,
249 img_width,
250 img_height,
251 area,
252 display,
253 is_tmux,
254 tmux_nest_count,
255 }
256 }
257 fn print_placeholder_grid(
258 &self,
259 w: &mut W,
260 ) -> Result<(), ProgramError> {
261 let id_str = if self.id < 256 {
262 format!("\u{1b}[38;5;{}m", self.id)
263 } else {
264 format!(
265 "\u{1b}[38;2;{};{};{}m",
266 (self.id >> 16) & 0xff,
267 (self.id >> 8) & 0xff,
268 self.id & 0xff
269 )
270 };
271 let id_msb_str = if self.id >= (1 << 24) {
272 DIACRITICS[self.id >> 24]
273 } else {
274 ""
275 };
276 for y in 0..(self.area.height).min(DIACRITICS.len() as u16) {
277 w.queue(cursor::MoveTo(self.area.left, self.area.top + y))?;
278 write!(w, "{}", &id_str)?;
279 if id_msb_str.is_empty() {
280 write!(w, "{}{}", PLACHOLDER, DIACRITICS[y as usize])?;
281 } else {
282 write!(
283 w,
284 "{}{}{}{}",
285 PLACHOLDER, DIACRITICS[y as usize], DIACRITICS[0], id_msb_str
286 )?;
287 }
288 write!(w, "{}", PLACHOLDER.repeat(self.area.width as usize - 1),)?;
289 write!(w, "\u{1b}[39m")?;
290 }
291 Ok(())
292 }
293 fn compress(data: &[u8]) -> Result<Vec<u8>, ProgramError> {
294 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
295 encoder.write_all(data).expect("Zlib encoder error");
296 Ok(encoder.finish().expect("Zlib encoder error"))
297 }
298 fn print_with_chunks(
301 &self,
302 w: &mut W,
303 ) -> Result<(), ProgramError> {
304 let esc = get_esc_seq(self.tmux_nest_count);
305 let tmux_header = self
306 .is_tmux
307 .then_some(get_tmux_header(self.tmux_nest_count));
308 let tmux_tail = self.is_tmux.then_some(get_tmux_tail(self.tmux_nest_count));
309 let display_tag = match self.display {
310 KittyGraphicsDisplay::Unicode => "q=2,U=1,",
311 _ => "",
312 };
313 let mut png_buf = Vec::new();
314 let (bytes, compression_tag, format) = match &self.data {
315 KittyImageData::Png { path } => {
316 File::open(path)?.read_to_end(&mut png_buf)?;
318 (png_buf, "", "100")
319 }
320 KittyImageData::Image { data } => (
321 KittyImage::compress(&data.bytes())?,
322 "o=z,",
323 data.kitty_format(),
324 ),
325 };
326 let encoded = BASE64.encode(bytes);
327 let mut pos = 0;
328 if self.display == KittyGraphicsDisplay::Direct {
329 w.queue(cursor::MoveTo(self.area.left, self.area.top))?;
330 }
331 if let Some(s) = &tmux_header {
332 write!(w, "{s}")?;
333 }
334 write!(
335 w,
336 "{}_Gq=2,a=t,f={},t=d,i={},s={},v={},{}",
337 &esc, format, self.id, self.img_width, self.img_height, compression_tag,
338 )?;
339 loop {
340 if pos != 0 {
341 if let Some(s) = &tmux_header {
342 write!(w, "{s}")?;
343 }
344 write!(w, "{}_Gq=2,", &esc)?;
345 }
346 if pos + CHUNK_SIZE < encoded.len() {
347 write!(w, "m=1;{}{}\\", &encoded[pos..pos + CHUNK_SIZE], &esc)?;
348 pos += CHUNK_SIZE;
349 if let Some(s) = &tmux_tail {
350 write!(w, "{s}")?;
351 }
352 } else {
353 write!(w, "m=0;{}{}\\", &encoded[pos..encoded.len()], &esc)?;
355 if let Some(s) = &tmux_tail {
356 write!(w, "{s}")?;
357 }
358 if let Some(s) = &tmux_header {
360 write!(w, "{s}")?;
361 }
362 write!(
363 w,
364 "{}_G{}a=p,i={},c={},r={}{}\\",
365 &esc, display_tag, self.id, self.area.width, self.area.height, &esc,
366 )?;
367 if let Some(s) = &tmux_tail {
368 write!(w, "{s}")?;
369 }
370 if self.display == KittyGraphicsDisplay::Unicode {
371 self.print_placeholder_grid(w)?;
372 }
373 break;
374 }
375 }
376 Ok(())
377 }
378 fn print_with_path(
381 &self,
382 w: &mut W,
383 path: &Path,
384 format: &str,
385 transmission: &str,
386 ) -> Result<(), ProgramError> {
387 let esc = get_esc_seq(self.tmux_nest_count);
388 let tmux_header = self
389 .is_tmux
390 .then_some(get_tmux_header(self.tmux_nest_count));
391 let tmux_tail = self.is_tmux.then_some(get_tmux_tail(self.tmux_nest_count));
392 if self.display == KittyGraphicsDisplay::Direct {
393 w.queue(cursor::MoveTo(self.area.left, self.area.top))?;
394 }
395 let display_tag = match self.display {
396 KittyGraphicsDisplay::Unicode => "q=2,U=1,",
397 _ => "",
398 };
399 let path = path
400 .to_str()
401 .ok_or_else(|| io::Error::other("Path can't be converted to UTF8"))?;
402 let encoded_path = BASE64.encode(path);
403 if let KittyImageData::Image { data: _ } = self.data {
404 debug!("temp file written: {:?}", path);
405 }
406 if let Some(s) = &tmux_header {
407 write!(w, "{s}")?;
408 }
409 write!(
410 w,
411 "{}_G{}a=T,f={},t={},i={},s={},v={},c={},r={};{}{}\\",
412 &esc,
413 display_tag,
414 format,
415 transmission,
416 self.id,
417 self.img_width,
418 self.img_height,
419 self.area.width,
420 self.area.height,
421 encoded_path,
422 &esc,
423 )?;
424 if let Some(s) = &tmux_tail {
425 write!(w, "{s}")?;
426 }
427 if self.display == KittyGraphicsDisplay::Unicode {
428 self.print_placeholder_grid(w)?;
429 }
430 Ok(())
431 }
432 pub fn print_with_png(
435 &self,
436 w: &mut W,
437 ) -> Result<(), ProgramError> {
438 if let KittyImageData::Png { path } = &self.data {
440 self.print_with_path(w, path.as_path(), "100", "f")?;
441 };
442 Ok(())
443 }
444 pub fn print_with_temp_file(
448 &self,
449 w: &mut W,
450 temp_file: Option<File>, temp_file_path: &Path,
452 ) -> Result<(), ProgramError> {
453 if let KittyImageData::Image { data } = &self.data {
455 if let Some(mut temp_file) = temp_file {
456 temp_file.write_all(&data.bytes())?;
457 temp_file.flush()?;
458 debug!("file len: {}", temp_file.metadata().unwrap().len());
459 }
460 self.print_with_path(w, temp_file_path, data.kitty_format(), "t")?;
461 };
462 Ok(())
463 }
464}
465
466impl KittyImageRenderer {
467 pub fn new(mut options: KittyImageRendererOptions) -> Option<Self> {
469 if options.display == KittyGraphicsDisplay::Detect {
470 options.display = detect_kitty_graphics_protocol_display();
471 }
472 if options.display == KittyGraphicsDisplay::None {
473 return None;
474 }
475 let hasher = FxBuildHasher;
476 let temp_files = LruCache::with_hasher(options.kept_temp_files, hasher);
477 let options = if is_ssh() {
478 KittyImageRendererOptions {
479 transmission_medium: TransmissionMedium::Chunks,
480 ..options
481 }
482 } else {
483 options
484 };
485 cell_size_in_pixels()
486 .ok()
487 .map(|(cell_width, cell_height)| Self {
488 cell_width,
489 cell_height,
490 next_id: 1,
491 options,
492 temp_files,
493 })
494 }
495 pub fn delete_temp_files(&mut self) {
496 for (_, temp_file_path) in self.temp_files.into_iter() {
497 debug!("removing temp file: {:?}", temp_file_path);
498 if let Err(e) = std::fs::remove_file(temp_file_path) {
499 error!("failed to remove temp file: {:?}", e);
500 }
501 }
502 }
503 fn new_id(&mut self) -> usize {
505 let new_id = self.next_id;
506 self.next_id += 1;
507 new_id
508 }
509 fn is_path_png(path: &Path) -> bool {
510 match path.extension() {
511 Some(ext) => ext == "png" || ext == "PNG",
512 None => false,
513 }
514 }
515 pub fn print(
518 &mut self,
519 w: &mut W,
520 src: &DynamicImage,
521 src_path: &Path,
522 area: &Area,
523 bg: Color,
524 ) -> Result<usize, ProgramError> {
525 for y in area.top..area.top + area.height {
527 w.queue(cursor::MoveTo(area.left, y))?;
528 fill_bg(w, area.width as usize, bg)?;
529 }
530
531 let png_path = KittyImageRenderer::is_path_png(src_path).then_some(src_path.to_path_buf());
532 let is_png = png_path.is_some();
533 let img = KittyImage::new(src, png_path, area, self);
534 debug!(
535 "transmission medium: {:?}",
536 self.options.transmission_medium
537 );
538 w.flush()?;
539 match self.options.transmission_medium {
540 TransmissionMedium::TempFile if is_png => {
541 img.print_with_png(w)?;
542 }
543 TransmissionMedium::TempFile => {
544 let temp_file_key = format!("{:?}-{}x{}", src_path, img.img_width, img.img_height,);
545 let mut old_path = None;
546 if let Some(cached_path) = self.temp_files.pop(&temp_file_key) {
547 if cached_path.exists() {
548 old_path = Some(cached_path);
549 }
550 }
551 let temp_file_path = if let Some(temp_file_path) = old_path {
552 img.print_with_temp_file(w, None, &temp_file_path)?;
554 temp_file_path
555 } else {
556 let (temp_file, path) = tempfile::Builder::new()
560 .prefix("broot-tty-graphics-protocol-")
561 .tempfile()?
562 .keep()
563 .map_err(|_| io::Error::other("temp file can't be kept"))?;
564 img.print_with_temp_file(w, Some(temp_file), &path)?;
565 path
566 };
567 if let Some((_, old_path)) = self.temp_files.push(temp_file_key, temp_file_path) {
568 debug!("removing temp file: {:?}", &old_path);
569 if let Err(e) = std::fs::remove_file(&old_path) {
570 error!("failed to remove temp file: {:?}", e);
571 }
572 }
573 }
574 TransmissionMedium::Chunks => img.print_with_chunks(w)?,
575 }
576 Ok(img.id)
577 }
578 fn rendering_area(
579 &self,
580 img_width: u32,
581 img_height: u32,
582 area: &Area,
583 ) -> Area {
584 let area_cols: u32 = area.width.into();
585 let area_rows: u32 = area.height.into();
586 let rdim = self.rendering_dim(img_width, img_height, area_cols, area_rows);
587 Area::new(
588 area.left + ((area_cols - rdim.0) / 2) as u16,
589 area.top + ((area_rows - rdim.1) / 2) as u16,
590 rdim.0 as u16,
591 rdim.1 as u16,
592 )
593 }
594 fn rendering_dim(
595 &self,
596 img_width: u32,
597 img_height: u32,
598 area_cols: u32,
599 area_rows: u32,
600 ) -> (u32, u32) {
601 let optimal_cols = div_ceil(img_width, self.cell_width);
602 let optimal_rows = div_ceil(img_height, self.cell_height);
603 debug!("area: {:?}", (area_cols, area_rows));
604 debug!("optimal: {:?}", (optimal_cols, optimal_rows));
605 if optimal_cols <= area_cols && optimal_rows <= area_rows {
606 (optimal_cols, optimal_rows)
608 } else if optimal_cols * area_rows > optimal_rows * area_cols {
609 debug!("constrained in width");
611 (area_cols, optimal_rows * area_cols / optimal_cols)
612 } else {
613 debug!("constrained in height");
615 (optimal_cols * area_rows / optimal_rows, area_rows)
616 }
617 }
618}