From a9cd24fe6557ad23f47e48bb0c0c99a727938f46 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Tue, 23 Jan 2024 16:22:21 -0800 Subject: [PATCH] Use unicode placeholders to print images under kitty+tmux --- src/main.rs | 10 ++- src/media/emulator.rs | 17 +++-- src/media/graphics.rs | 36 ++-------- src/media/kitty.rs | 140 ++++++++++++++++++++++++++++++++------ src/media/printer.rs | 7 +- src/media/scale.rs | 1 + src/processing/builder.rs | 6 +- 7 files changed, 151 insertions(+), 66 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4807c68..f4809b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,8 +72,12 @@ impl TryFrom<&ImageProtocol> for GraphicsMode { emulator.preferred_protocol() } ImageProtocol::Iterm2 => GraphicsMode::Iterm2, - ImageProtocol::KittyLocal => GraphicsMode::Kitty(KittyMode::Local), - ImageProtocol::KittyRemote => GraphicsMode::Kitty(KittyMode::Remote), + ImageProtocol::KittyLocal => { + GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: TerminalEmulator::is_inside_tmux() } + } + ImageProtocol::KittyRemote => { + GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux: TerminalEmulator::is_inside_tmux() } + } ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks, #[cfg(feature = "sixel")] ImageProtocol::Sixel => GraphicsMode::Sixel, @@ -212,7 +216,7 @@ fn run(mut cli: Cli) -> Result<(), Box> { } } else { let commands = CommandSource::new(&path, config.bindings.clone())?; - options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty(_)); + options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. }); let options = PresenterOptions { builder_options: options, diff --git a/src/media/emulator.rs b/src/media/emulator.rs index f480add..78b78a2 100644 --- a/src/media/emulator.rs +++ b/src/media/emulator.rs @@ -1,8 +1,8 @@ +use super::kitty::local_mode_supported; use crate::{GraphicsMode, KittyMode}; use std::env; -use super::kitty::local_mode_supported; - +#[derive(Debug)] pub enum TerminalEmulator { Kitty, Iterm2, @@ -12,6 +12,10 @@ pub enum TerminalEmulator { } impl TerminalEmulator { + pub fn is_inside_tmux() -> bool { + env::var("TERM_PROGRAM").ok().as_deref() == Some("tmux") + } + pub fn detect() -> Self { if Self::is_kitty() { Self::Kitty @@ -27,10 +31,11 @@ impl TerminalEmulator { } pub fn preferred_protocol(&self) -> GraphicsMode { + let inside_tmux = Self::is_inside_tmux(); let modes = [ GraphicsMode::Iterm2, - GraphicsMode::Kitty(KittyMode::Local), - GraphicsMode::Kitty(KittyMode::Remote), + GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux }, + GraphicsMode::Kitty { mode: KittyMode::Remote, inside_tmux }, #[cfg(feature = "sixel")] GraphicsMode::Sixel, GraphicsMode::AsciiBlocks, @@ -45,8 +50,8 @@ impl TerminalEmulator { fn supports_graphics_mode(&self, mode: &GraphicsMode) -> bool { match (mode, self) { - (GraphicsMode::Kitty(mode), Self::Kitty | Self::WezTerm) => match mode { - KittyMode::Local => local_mode_supported().unwrap_or_default(), + (GraphicsMode::Kitty { mode, inside_tmux }, Self::Kitty | Self::WezTerm) => match mode { + KittyMode::Local => local_mode_supported(*inside_tmux).unwrap_or_default(), KittyMode::Remote => true, }, (GraphicsMode::Iterm2, Self::Iterm2 | Self::WezTerm | Self::Mintty) => true, diff --git a/src/media/graphics.rs b/src/media/graphics.rs index 003f680..0a193fe 100644 --- a/src/media/graphics.rs +++ b/src/media/graphics.rs @@ -1,46 +1,18 @@ use super::kitty::KittyMode; -use viuer::{get_kitty_support, is_iterm_supported, KittySupport}; #[derive(Clone, Debug)] pub enum GraphicsMode { Iterm2, - Kitty(KittyMode), + Kitty { + mode: KittyMode, + inside_tmux: bool, + }, AsciiBlocks, #[cfg(feature = "sixel")] Sixel, } -impl Default for GraphicsMode { - fn default() -> Self { - let modes = &[ - Self::Iterm2, - Self::Kitty(KittyMode::Local), - Self::Kitty(KittyMode::Remote), - #[cfg(feature = "sixel")] - Self::Sixel, - Self::AsciiBlocks, - ]; - for mode in modes { - if mode.is_supported() { - return mode.clone(); - } - } - Self::AsciiBlocks - } -} - impl GraphicsMode { - pub fn is_supported(&self) -> bool { - match self { - Self::Iterm2 => is_iterm_supported(), - Self::Kitty(KittyMode::Local) => get_kitty_support() == KittySupport::Local, - Self::Kitty(KittyMode::Remote) => get_kitty_support() == KittySupport::Remote, - Self::AsciiBlocks => true, - #[cfg(feature = "sixel")] - Self::Sixel => viuer::is_sixel_supported(), - } - } - pub fn detect_graphics_protocol() { viuer::is_iterm_supported(); viuer::get_kitty_support(); diff --git a/src/media/kitty.rs b/src/media/kitty.rs index c3514a4..a052017 100644 --- a/src/media/kitty.rs +++ b/src/media/kitty.rs @@ -1,6 +1,8 @@ use super::printer::{PrintImage, PrintImageError, PrintOptions, RegisterImageError, ResourceProperties}; +use crate::style::Color; use base64::{engine::general_purpose::STANDARD, Engine}; use console::{Key, Term}; +use crossterm::{cursor::MoveToColumn, style::SetForegroundColor, QueueableCommand}; use image::{codecs::gif::GifDecoder, io::Reader, AnimationDecoder, Delay, DynamicImage, EncodableLayout, RgbaImage}; use rand::Rng; use std::{ @@ -9,9 +11,35 @@ use std::{ io::{self, BufReader, Write}, path::{Path, PathBuf}, sync::atomic::{AtomicU32, Ordering}, + u8, }; use tempfile::{tempdir, NamedTempFile, TempDir}; +const IMAGE_PLACEHOLDER: &str = "\u{10EEEE}"; +const DIACRITICS: &[u32] = &[ + 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357, + 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484, + 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1, + 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658, + 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2, + 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743, + 0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819, + 0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c, + 0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75, + 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72, + 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7, + 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda, + 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0, + 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1, + 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded, + 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb, + 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4, + 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0, + 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26, + 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, + 0x1d243, 0x1d244, +]; + enum GenericResource { Image(B), Gif(Vec>), @@ -70,14 +98,15 @@ struct GifFrame { pub struct KittyPrinter { mode: KittyMode, + tmux: bool, base_directory: TempDir, next: AtomicU32, } impl KittyPrinter { - pub(crate) fn new(mode: KittyMode) -> io::Result { + pub(crate) fn new(mode: KittyMode, tmux: bool) -> io::Result { let base_directory = tempdir()?; - Ok(Self { mode, base_directory, next: Default::default() }) + Ok(Self { mode, tmux, base_directory, next: Default::default() }) } fn allocate_tempfile(&self) -> PathBuf { @@ -115,6 +144,10 @@ impl KittyPrinter { } } + fn generate_image_id() -> u32 { + rand::thread_rng().gen_range(1..u32::MAX) + } + fn print_image( &self, dimensions: (u32, u32), @@ -125,7 +158,7 @@ impl KittyPrinter { where W: io::Write, { - let options = vec![ + let mut options = vec![ ControlOption::Format(ImageFormat::Rgba), ControlOption::Action(Action::TransmitAndDisplay), ControlOption::Width(dimensions.0), @@ -135,11 +168,21 @@ impl KittyPrinter { ControlOption::ZIndex(print_options.z_index), ControlOption::Quiet(2), ]; + let mut image_id = 0; + if self.tmux { + image_id = Self::generate_image_id(); + options.extend([ControlOption::UnicodePlaceholder, ControlOption::ImageId(image_id)]); + } match &buffer { - KittyBuffer::Filesystem(path) => Self::print_local(options, path, writer), - KittyBuffer::Memory(buffer) => Self::print_remote(options, buffer, writer, false), + KittyBuffer::Filesystem(path) => self.print_local(options, path, writer)?, + KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, writer, false)?, + }; + if self.tmux { + self.print_unicode_placeholders(writer, print_options, image_id)?; } + + Ok(()) } fn print_gif( @@ -152,10 +195,10 @@ impl KittyPrinter { where W: io::Write, { - let image_id = rand::thread_rng().gen(); + let image_id = Self::generate_image_id(); for (frame_id, frame) in frames.iter().enumerate() { let (num, denom) = frame.delay.numer_denom_ms(); - // default to 100ms in case somehow the denomiator is 0 + // default to 100ms in case somehow the denominator is 0 let delay = num.checked_div(denom).unwrap_or(100); let mut options = vec![ ControlOption::Format(ImageFormat::Rgba), @@ -171,14 +214,17 @@ impl KittyPrinter { ControlOption::Columns(print_options.columns), ControlOption::Rows(print_options.rows), ]); + if self.tmux { + options.push(ControlOption::UnicodePlaceholder); + } } else { options.extend([ControlOption::Action(Action::TransmitFrame), ControlOption::Delay(delay)]); } let is_frame = frame_id > 0; match &frame.buffer { - KittyBuffer::Filesystem(path) => Self::print_local(options, path, writer)?, - KittyBuffer::Memory(buffer) => Self::print_remote(options, buffer, writer, is_frame)?, + KittyBuffer::Filesystem(path) => self.print_local(options, path, writer)?, + KittyBuffer::Memory(buffer) => self.print_remote(options, buffer, writer, is_frame)?, }; if frame_id == 0 { @@ -188,7 +234,7 @@ impl KittyPrinter { ControlOption::FrameId(1), ControlOption::Loops(1), ]; - let command = ControlCommand(options, ""); + let command = self.make_command(options, ""); write!(writer, "{command}")?; } else if frame_id == 1 { let options = &[ @@ -197,10 +243,13 @@ impl KittyPrinter { ControlOption::FrameId(1), ControlOption::AnimationState(2), ]; - let command = ControlCommand(options, ""); + let command = self.make_command(options, ""); write!(writer, "{command}")?; } } + if self.tmux { + self.print_unicode_placeholders(writer, print_options, image_id)?; + } let options = &[ ControlOption::Action(Action::Animate), ControlOption::ImageId(image_id), @@ -209,12 +258,21 @@ impl KittyPrinter { ControlOption::Loops(1), ControlOption::Quiet(2), ]; - let command = ControlCommand(options, ""); + let command = self.make_command(options, ""); write!(writer, "{command}")?; Ok(()) } - fn print_local(mut options: Vec, path: &Path, writer: &mut W) -> Result<(), PrintImageError> + fn make_command<'a, P>(&self, options: &'a [ControlOption], payload: P) -> ControlCommand<'a, P> { + ControlCommand { options, payload, tmux: self.tmux } + } + + fn print_local( + &self, + mut options: Vec, + path: &Path, + writer: &mut W, + ) -> Result<(), PrintImageError> where W: io::Write, { @@ -224,12 +282,13 @@ impl KittyPrinter { let encoded_path = STANDARD.encode(path); options.push(ControlOption::Medium(TransmissionMedium::LocalFile)); - let command = ControlCommand(&options, &encoded_path); + let command = self.make_command(&options, &encoded_path); write!(writer, "{command}")?; Ok(()) } fn print_remote( + &self, mut options: Vec, frame: &[u8], writer: &mut W, @@ -252,7 +311,7 @@ impl KittyPrinter { options.push(ControlOption::MoreData(more)); let payload = &payload[start..end]; - let command = ControlCommand(&options, payload); + let command = self.make_command(&options, payload); write!(writer, "{command}")?; options.clear(); @@ -263,6 +322,33 @@ impl KittyPrinter { Ok(()) } + fn print_unicode_placeholders( + &self, + writer: &mut W, + options: &PrintOptions, + image_id: u32, + ) -> Result<(), PrintImageError> { + let color = Color::new((image_id >> 16) as u8, (image_id >> 8) as u8, image_id as u8); + writer.queue(SetForegroundColor(color.into()))?; + if options.rows.max(options.columns) >= DIACRITICS.len() as u16 { + return Err(PrintImageError::other("image is too large to fit in tmux")); + } + + let last_byte = char::from_u32(DIACRITICS[(image_id >> 24) as usize]).unwrap(); + for row in 0..options.rows { + let row_diacritic = char::from_u32(DIACRITICS[row as usize]).unwrap(); + for column in 0..options.columns { + let column_diacritic = char::from_u32(DIACRITICS[column as usize]).unwrap(); + write!(writer, "{IMAGE_PLACEHOLDER}{row_diacritic}{column_diacritic}{last_byte}")?; + } + if row != options.rows - 1 { + writeln!(writer)?; + } + writer.queue(MoveToColumn(options.cursor_position.column))?; + } + Ok(()) + } + fn load_raw_resource(path: &Path) -> Result { let file = File::open(path)?; if path.extension().unwrap_or_default() == "gif" { @@ -324,18 +410,30 @@ pub enum KittyMode { Remote, } -struct ControlCommand<'a, D>(&'a [ControlOption], D); +struct ControlCommand<'a, D> { + options: &'a [ControlOption], + payload: D, + tmux: bool, +} impl<'a, D: fmt::Display> fmt::Display for ControlCommand<'a, D> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.tmux { + write!(f, "\x1bPtmux;\x1b")?; + } write!(f, "\x1b_G")?; - for (index, option) in self.0.iter().enumerate() { + for (index, option) in self.options.iter().enumerate() { if index > 0 { write!(f, ",")?; } write!(f, "{option}")?; } - write!(f, ";{}\x1b\\", &self.1)?; + write!(f, ";{}", &self.payload)?; + if self.tmux { + write!(f, "\x1b\x1b\\\x1b\\")?; + } else { + write!(f, "\x1b\\")?; + } Ok(()) } } @@ -357,6 +455,7 @@ enum ControlOption { Loops(u32), Quiet(u32), ZIndex(i32), + UnicodePlaceholder, } impl fmt::Display for ControlOption { @@ -379,6 +478,7 @@ impl fmt::Display for ControlOption { Loops(count) => write!(f, "v={count}"), Quiet(option) => write!(f, "q={option}"), ZIndex(index) => write!(f, "z={index}"), + UnicodePlaceholder => write!(f, "U=1"), } } } @@ -436,7 +536,7 @@ impl fmt::Display for Action { } } -pub(crate) fn local_mode_supported() -> io::Result { +pub(crate) fn local_mode_supported(inside_tmux: bool) -> io::Result { let mut file = NamedTempFile::new()?; let image = DynamicImage::new_rgba8(1, 1); file.write_all(image.into_rgba8().as_raw().as_bytes())?; @@ -455,7 +555,7 @@ pub(crate) fn local_mode_supported() -> io::Result { ControlOption::Height(1), ]; let mut writer = io::stdout(); - let command = ControlCommand(options, encoded_path); + let command = ControlCommand { options, payload: encoded_path, tmux: inside_tmux }; write!(writer, "{command}")?; writer.flush()?; diff --git a/src/media/printer.rs b/src/media/printer.rs index ca5ca07..fe6e854 100644 --- a/src/media/printer.rs +++ b/src/media/printer.rs @@ -27,6 +27,7 @@ pub(crate) trait ResourceProperties { fn dimensions(&self) -> (u32, u32); } +#[derive(Debug)] pub(crate) struct PrintOptions { pub(crate) columns: u16, pub(crate) rows: u16, @@ -65,7 +66,7 @@ impl Default for ImagePrinter { impl ImagePrinter { pub fn new(mode: GraphicsMode) -> io::Result { let printer = match mode { - GraphicsMode::Kitty(mode) => Self::new_kitty(mode)?, + GraphicsMode::Kitty { mode, inside_tmux } => Self::new_kitty(mode, inside_tmux)?, GraphicsMode::Iterm2 => Self::new_iterm(), GraphicsMode::AsciiBlocks => Self::new_ascii(), #[cfg(feature = "sixel")] @@ -74,8 +75,8 @@ impl ImagePrinter { Ok(printer) } - fn new_kitty(mode: KittyMode) -> io::Result { - Ok(Self::Kitty(KittyPrinter::new(mode)?)) + fn new_kitty(mode: KittyMode, inside_tmux: bool) -> io::Result { + Ok(Self::Kitty(KittyPrinter::new(mode, inside_tmux)?)) } fn new_iterm() -> Self { diff --git a/src/media/scale.rs b/src/media/scale.rs index 099fb37..df27dff 100644 --- a/src/media/scale.rs +++ b/src/media/scale.rs @@ -28,6 +28,7 @@ pub(crate) fn scale_image( // Don't go too far wide. let width_in_columns = width_in_columns.min(column_margin); let height_in_rows = (width_in_columns as f64 * aspect_ratio / 2.0) as u16; + let height_in_rows = height_in_rows.max(1); // Draw it in the middle let start_column = dimensions.columns / 2 - (width_in_columns / 2) as u16; diff --git a/src/processing/builder.rs b/src/processing/builder.rs index 2a6d73d..eedacd9 100644 --- a/src/processing/builder.rs +++ b/src/processing/builder.rs @@ -501,8 +501,10 @@ impl<'a> PresentationBuilder<'a> { fn push_image(&mut self, image: Image) { let properties = ImageProperties { z_index: DEFAULT_Z_INDEX, size: Default::default(), restore_cursor: false }; - self.chunk_operations.push(RenderOperation::RenderImage(image, properties)); - self.chunk_operations.push(RenderOperation::SetColors(self.theme.default_style.colors.clone())); + self.chunk_operations.extend([ + RenderOperation::RenderImage(image, properties), + RenderOperation::SetColors(self.theme.default_style.colors.clone()), + ]); } fn push_list(&mut self, list: Vec) {