diff --git a/README.md b/README.md index 61dbd277..237937b3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ brew install jq unar ffmpegthumbnailer fd ripgrep fzf zoxide brew tap homebrew/cask-fonts && brew install --cask font-symbols-only-nerd-font ``` -And download the latest release [from here](https://github.com/sxyazi/yazi/releases). Or you can install Yazi with cargo: +And download the latest release [from here](https://github.com/sxyazi/yazi/releases). Or you can install Yazi via cargo: ```bash cargo install --git https://github.com/sxyazi/yazi.git @@ -66,6 +66,25 @@ yazi If you want to use your own config, copy the [config folder](https://github.com/sxyazi/yazi/tree/main/config) to `~/.config/yazi`, and modify it as you like. +## Image Preview + +| Platform | Protocol | Support | +| ----------- | -------------------------------------------------------------------------------- | --------------------- | +| Kitty | [Terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) | ✅ Built-in | +| WezTerm | [Terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) | ✅ Built-in | +| Konsole | [Terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) | ✅ Built-in | +| iTerm2 | [Inline Images Protocol](https://iterm2.com/documentation-images.html) | ✅ Built-in | +| Hyper | Sixel | ☑️ Überzug++ required | +| foot | Sixel | ☑️ Überzug++ required | +| X11/Wayland | Window system protocol | ☑️ Überzug++ required | +| Fallback | [Chafa](https://hpjansson.org/chafa/) | ☑️ Überzug++ required | + +Yazi automatically selects the appropriate preview method for you, based on the priority from top to bottom. +That's relying on the `$TERM`, `$TERM_PROGRAM`, and `$XDG_SESSION_TYPE` variables, make sure you don't overwrite them by mistake! + +For instance, if your terminal is Alacritty, which doesn't support displaying images itself, but you are running on an X11/Wayland environment, +it will automatically use the "Window system protocol" to display images -- this requires you to have [Überzug++](https://github.com/jstkdng/ueberzugpp) installed. + ## TODO - [x] Add example config for general usage, currently please see my [another repo](https://github.com/sxyazi/dotfiles/tree/main/yazi) instead diff --git a/cspell.json b/cspell.json index 702a1da5..d6f8dc01 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp"],"language":"en","version":"0.2"} +{"flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","️ Überzug","️ Überzug","Konsole","Alacritty"],"version":"0.2","language":"en"} diff --git a/src/config/preview/adaptor.rs b/src/config/preview/adaptor.rs index c1cb9e26..25cfb98a 100644 --- a/src/config/preview/adaptor.rs +++ b/src/config/preview/adaptor.rs @@ -14,13 +14,22 @@ pub enum PreviewAdaptor { impl Default for PreviewAdaptor { fn default() -> Self { + if env::var("KITTY_WINDOW_ID").is_ok() { + return Self::Kitty; + } + if env::var("KONSOLE_VERSION").is_ok() { + return Self::Kitty; + } match env::var("TERM").unwrap_or_default().as_str() { - "wezterm" => return Self::Kitty, "xterm-kitty" => return Self::Kitty, - "iterm2" => return Self::Iterm2, + "wezterm" => return Self::Kitty, "foot" => return Self::Sixel, _ => {} } + match env::var("TERM_PROGRAM").unwrap_or_default().as_str() { + "iTerm.app" => return Self::Iterm2, + _ => {} + } match env::var("XDG_SESSION_TYPE").unwrap_or_default().as_str() { "x11" => return Self::X11, "wayland" => return Self::Wayland, diff --git a/src/core/adaptor/adaptor.rs b/src/core/adaptor/adaptor.rs index 9fda1726..df69eb31 100644 --- a/src/core/adaptor/adaptor.rs +++ b/src/core/adaptor/adaptor.rs @@ -1,33 +1,33 @@ -use std::path::{Path, PathBuf}; +use std::{path::{Path, PathBuf}, sync::atomic::{AtomicBool, Ordering}}; use anyhow::Result; +use once_cell::sync::Lazy; use ratatui::prelude::Rect; use tokio::sync::mpsc::UnboundedSender; use super::{iterm2::Iterm2, kitty::Kitty, ueberzug::Ueberzug}; use crate::config::{preview::PreviewAdaptor, PREVIEW}; -pub struct Adaptor { - ueberzug: Option>>, -} +static IMAGE_SHOWN: AtomicBool = AtomicBool::new(false); + +static UEBERZUG: Lazy>>> = + Lazy::new(|| if PREVIEW.adaptor.needs_ueberzug() { Ueberzug::init().ok() } else { None }); + +pub struct Adaptor; impl Adaptor { - pub fn new() -> Self { - let mut adaptor = Self { ueberzug: None }; + pub fn init() { Lazy::force(&UEBERZUG); } - if PREVIEW.adaptor.needs_ueberzug() { - adaptor.ueberzug = Ueberzug::init().ok(); + pub async fn image_show(path: &Path, rect: Rect) -> Result<()> { + if IMAGE_SHOWN.swap(true, Ordering::Relaxed) { + Self::image_hide(rect); } - adaptor - } - - pub async fn image_show(&self, path: &Path, rect: Rect) -> Result<()> { match PREVIEW.adaptor { PreviewAdaptor::Kitty => Kitty::image_show(path, rect).await, PreviewAdaptor::Iterm2 => Iterm2::image_show(path, rect).await, _ => { - if let Some(tx) = &self.ueberzug { + if let Some(tx) = &*UEBERZUG { tx.send(Some((path.to_path_buf(), rect))).ok(); } Ok(()) @@ -35,12 +35,16 @@ impl Adaptor { } } - pub fn image_hide(&self) { + pub fn image_hide(rect: Rect) { + if !IMAGE_SHOWN.swap(false, Ordering::Relaxed) { + return; + } + match PREVIEW.adaptor { PreviewAdaptor::Kitty => Kitty::image_hide(), - PreviewAdaptor::Iterm2 => {} + PreviewAdaptor::Iterm2 => Iterm2::image_hide(rect), _ => { - if let Some(tx) = &self.ueberzug { + if let Some(tx) = &*UEBERZUG { tx.send(None).ok(); } } diff --git a/src/core/adaptor/iterm2.rs b/src/core/adaptor/iterm2.rs index fd5766fc..7200461a 100644 --- a/src/core/adaptor/iterm2.rs +++ b/src/core/adaptor/iterm2.rs @@ -2,7 +2,7 @@ use std::{io::Write, path::Path}; use anyhow::Result; use base64::{engine::general_purpose, Engine}; -use image::DynamicImage; +use image::{codecs::jpeg::JpegEncoder, DynamicImage}; use ratatui::prelude::Rect; use tokio::io::AsyncWriteExt; @@ -21,29 +21,32 @@ impl Iterm2 { Ok(()) } - async fn encode(img: DynamicImage) -> Result> { - fn output(raw: Vec, size: (u32, u32)) -> Result> { - let mut buf = Vec::with_capacity(raw.len() * 4 / 3 + 4 + 200); + #[inline] + pub(super) fn image_hide(rect: Rect) { + let s = " ".repeat(rect.width as usize); + for y in rect.top()..=rect.bottom() { + Term::move_to(rect.x, y).ok(); + std::io::stdout().write_all(s.as_bytes()).ok(); + } + } + async fn encode(img: DynamicImage) -> Result> { + tokio::task::spawn_blocking(move || { + let size = (img.width(), img.height()); + + let mut jpg = vec![]; + JpegEncoder::new_with_quality(&mut jpg, 75).encode_image(&img)?; + + let mut buf = vec![]; write!( buf, - "\x1b]1337;File=inline=1;size={};width={}px;height={}px:", - raw.len(), + "\x1b]1337;File=inline=1;size={};width={}px;height={}px:{}\x07", + jpg.len(), size.0, size.1, + general_purpose::STANDARD.encode(&jpg) )?; - - let len = buf.len(); - let written = general_purpose::STANDARD.encode_slice(raw, &mut buf[len..])?; - buf.truncate(len + written); Ok(buf) - } - - let size = (img.width(), img.height()); - tokio::task::spawn_blocking(move || match img { - DynamicImage::ImageRgb8(v) => output(v.into_raw(), size), - DynamicImage::ImageRgba8(v) => output(v.into_raw(), size), - v => output(v.to_rgb8().into_raw(), size), }) .await? } diff --git a/src/core/adaptor/kitty.rs b/src/core/adaptor/kitty.rs index fa41c096..57ed456c 100644 --- a/src/core/adaptor/kitty.rs +++ b/src/core/adaptor/kitty.rs @@ -25,7 +25,7 @@ impl Kitty { pub(super) fn image_hide() { std::io::stdout().write_all(b"\x1b\\\x1b_Ga=d\x1b\\").ok(); } async fn encode(img: DynamicImage) -> Result> { - fn output(raw: Vec, format: u8, size: (u32, u32)) -> Result> { + fn output(raw: &[u8], format: u8, size: (u32, u32)) -> Result> { let b64 = general_purpose::STANDARD.encode(raw).chars().collect::>(); let mut it = b64.chunks(4096).peekable(); @@ -55,9 +55,9 @@ impl Kitty { let size = (img.width(), img.height()); tokio::task::spawn_blocking(move || match img { - DynamicImage::ImageRgb8(v) => output(v.into_raw(), 24, size), - DynamicImage::ImageRgba8(v) => output(v.into_raw(), 32, size), - v => output(v.to_rgb8().into_raw(), 24, size), + DynamicImage::ImageRgb8(v) => output(v.as_raw(), 24, size), + DynamicImage::ImageRgba8(v) => output(v.as_raw(), 32, size), + v => output(v.to_rgb8().as_raw(), 24, size), }) .await? } diff --git a/src/core/adaptor/ueberzug.rs b/src/core/adaptor/ueberzug.rs index 84ea2ddf..e08c2f95 100644 --- a/src/core/adaptor/ueberzug.rs +++ b/src/core/adaptor/ueberzug.rs @@ -14,7 +14,7 @@ impl Ueberzug { let (tx, mut rx) = mpsc::unbounded_channel(); tokio::spawn(async move { - while let Some(img) = rx.recv().await { + while let Some(cmd) = rx.recv().await { let exit = child.as_mut().and_then(|c| c.try_wait().ok()); if exit != Some(None) { child = None; @@ -23,7 +23,7 @@ impl Ueberzug { child = Self::create_demon().ok(); } if let Some(c) = &mut child { - Self::send_command(c, img).await.ok(); + Self::send_command(c, cmd).await.ok(); } } }); @@ -42,10 +42,10 @@ impl Ueberzug { ) } - async fn send_command(child: &mut Child, img: Option<(PathBuf, Rect)>) -> Result<()> { + async fn send_command(child: &mut Child, cmd: Option<(PathBuf, Rect)>) -> Result<()> { let stdin = child.stdin.as_mut().unwrap(); - if let Some((path, rect)) = img { - let cmd = format!( + if let Some((path, rect)) = cmd { + let s = format!( r#"{{"action":"add","identifier":"yazi","x":{},"y":{},"max_width":{},"max_height":{},"path":"{}"}}{}"#, rect.x, rect.y, @@ -54,7 +54,7 @@ impl Ueberzug { path.to_string_lossy(), "\n" ); - stdin.write_all(cmd.as_bytes()).await?; + stdin.write_all(s.as_bytes()).await?; } else { stdin .write_all(format!(r#"{{"action":"remove","identifier":"yazi"}}{}"#, "\n").as_bytes()) diff --git a/src/core/manager/preview.rs b/src/core/manager/preview.rs index 7bb373af..2705a32b 100644 --- a/src/core/manager/preview.rs +++ b/src/core/manager/preview.rs @@ -1,4 +1,4 @@ -use std::{fs::File, io::{BufRead, BufReader}, path::{Path, PathBuf}, sync::{Arc, OnceLock}}; +use std::{fs::File, io::{BufRead, BufReader}, path::{Path, PathBuf}, sync::OnceLock}; use anyhow::{anyhow, Result}; use ratatui::prelude::Rect; @@ -15,8 +15,7 @@ pub struct Preview { pub path: PathBuf, pub data: PreviewData, - handle: Option>, - adaptor: Arc, + handle: Option>, } #[derive(Debug, Default)] @@ -33,8 +32,7 @@ impl Preview { path: Default::default(), data: Default::default(), - handle: Default::default(), - adaptor: Arc::new(Adaptor::new()), + handle: Default::default(), } } @@ -55,16 +53,14 @@ impl Preview { pub fn go(&mut self, path: &Path, mime: &str) { self.reset(); - let adaptor = self.adaptor.clone(); let (path, mime) = (path.to_path_buf(), mime.to_owned()); - self.handle = Some(tokio::spawn(async move { let result = match MimeKind::new(&mime) { MimeKind::Dir => Self::folder(&path).await, MimeKind::JSON => Self::json(&path).await.map(PreviewData::Text), MimeKind::Text => Self::highlight(&path).await.map(PreviewData::Text), - MimeKind::Image => Self::image(adaptor, &path).await, - MimeKind::Video => Self::video(adaptor, &path).await, + MimeKind::Image => Self::image(&path).await, + MimeKind::Video => Self::video(&path).await, MimeKind::Archive => Self::archive(&path).await.map(PreviewData::Text), MimeKind::Others => Err(anyhow!("Unsupported mimetype: {}", mime)), }; @@ -75,7 +71,7 @@ impl Preview { pub fn reset(&mut self) -> bool { self.handle.take().map(|h| h.abort()); - self.adaptor.image_hide(); + Adaptor::image_hide(Self::rect()); if self.path == PathBuf::default() { return false; @@ -95,23 +91,23 @@ impl Preview { Ok(PreviewData::Folder) } - pub async fn image(adaptor: Arc, mut path: &Path) -> Result { + pub async fn image(mut path: &Path) -> Result { let cache = Precache::cache(path); if fs::metadata(&cache).await.is_ok() { path = cache.as_path(); } - adaptor.image_show(path, Self::rect()).await?; + Adaptor::image_show(path, Self::rect()).await?; Ok(PreviewData::None) } - pub async fn video(adaptor: Arc, path: &Path) -> Result { + pub async fn video(path: &Path) -> Result { let cache = Precache::cache(path); if fs::metadata(&cache).await.is_err() { external::ffmpegthumbnailer(path, &cache).await?; } - Self::image(adaptor, &cache).await + Self::image(&cache).await } pub async fn json(path: &Path) -> Result { diff --git a/src/main.rs b/src/main.rs index ed61b898..53e53f62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use core::adaptor; + use ui::App; mod config; @@ -11,5 +13,7 @@ async fn main() -> anyhow::Result<()> { config::init(); + adaptor::Adaptor::init(); + App::run().await } diff --git a/src/ui/manager/preview.rs b/src/ui/manager/preview.rs index f3a0b92c..0915dc2b 100644 --- a/src/ui/manager/preview.rs +++ b/src/ui/manager/preview.rs @@ -2,7 +2,7 @@ use ansi_to_tui::IntoText; use ratatui::{buffer::Buffer, layout::Rect, widgets::{Clear, Paragraph, Widget}}; use super::Folder; -use crate::{core::manager::PreviewData, ui::Ctx}; +use crate::{core::manager::{PreviewData, PREVIEW_BORDER}, ui::Ctx}; pub struct Preview<'a> { cx: &'a Ctx, @@ -14,7 +14,15 @@ impl<'a> Preview<'a> { impl<'a> Widget for Preview<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - Clear.render(Rect { x: area.x, y: area.y, width: area.width + 1, height: area.height }, buf); + Clear.render( + Rect { + x: area.x, + y: area.y, + width: area.width + PREVIEW_BORDER / 2, + height: area.height, + }, + buf, + ); // TODO: image // if self.cx.input.visible || self.cx.select.visible || self.cx.tasks.visible {