diff --git a/Cargo.lock b/Cargo.lock index 84f49620..b4703982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,9 +961,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +checksum = "67827e6ea8ee8a7c4a72227ef4fc08957040acffdb5f122733b24fa12daff41b" [[package]] name = "md5" @@ -1425,7 +1425,7 @@ checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.3.3", + "regex-automata 0.3.4", "regex-syntax 0.7.4", ] @@ -1440,9 +1440,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310" +checksum = "b7b6d6190b7594385f61bd3911cd1be99dfddcfc365a4160cc2ab5bff4aed294" dependencies = [ "aho-corasick", "memchr", @@ -1502,18 +1502,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.176" +version = "1.0.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76dc28c9523c5d70816e393136b86d48909cfb27cecaa902d338c19ed47164dc" +checksum = "60363bdd39a7be0266a520dab25fdc9241d2f987b08a01e01f0ec6d06a981348" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.176" +version = "1.0.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e7b8c5dc823e3b90651ff1d3808419cd14e5ad76de04feaf37da114e7a306f" +checksum = "f28482318d6641454cb273da158647922d1be6b5a2fcc6165cd89ebdd7ed576b" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index 61dbd277..bf025fbb 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,13 +66,32 @@ 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 - [x] Integration with fzf, zoxide for fast directory navigation - [x] Integration with fd, rg for fuzzy file searching - [x] Documentation of commands and options -- [ ] Support for Überzug++ for image previews with X11/wayland environment +- [x] Support for Überzug++ for image previews with X11/wayland environment - [ ] Batch renaming support ## License diff --git a/cspell.json b/cspell.json index 109e30dc..d6f8dc01 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"language":"en","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"],"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 new file mode 100644 index 00000000..c0662d1c --- /dev/null +++ b/src/config/preview/adaptor.rs @@ -0,0 +1,64 @@ +use std::env; + +#[derive(Debug, PartialEq, Eq)] +pub enum PreviewAdaptor { + Kitty, + Iterm2, + + // Supported by Überzug++ + Sixel, + X11, + Wayland, + Chafa, +} + +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() { + "xterm-kitty" => return Self::Kitty, + "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, + _ => Self::Chafa, + } + } +} + +impl ToString for PreviewAdaptor { + fn to_string(&self) -> String { + match self { + PreviewAdaptor::Kitty => "kitty", + PreviewAdaptor::Iterm2 => "iterm2", + PreviewAdaptor::Sixel => "sixel", + PreviewAdaptor::X11 => "x11", + PreviewAdaptor::Wayland => "wayland", + PreviewAdaptor::Chafa => "chafa", + } + .to_string() + } +} + +impl PreviewAdaptor { + #[inline] + pub fn needs_ueberzug(&self) -> bool { + match self { + PreviewAdaptor::Kitty => false, + PreviewAdaptor::Iterm2 => false, + _ => true, + } + } +} diff --git a/src/config/preview/mod.rs b/src/config/preview/mod.rs index eacd60e4..612b0d97 100644 --- a/src/config/preview/mod.rs +++ b/src/config/preview/mod.rs @@ -1,3 +1,5 @@ +mod adaptor; mod preview; +pub use adaptor::*; pub use preview::*; diff --git a/src/config/preview/preview.rs b/src/config/preview/preview.rs index a471305e..613ca510 100644 --- a/src/config/preview/preview.rs +++ b/src/config/preview/preview.rs @@ -1,9 +1,12 @@ use serde::Deserialize; +use super::PreviewAdaptor; use crate::config::MERGED_YAZI; #[derive(Debug, Deserialize)] pub struct Preview { + #[serde(skip)] + pub adaptor: PreviewAdaptor, pub tab_size: u32, pub max_width: u32, diff --git a/src/core/adapter/kitty.rs b/src/core/adapter/kitty.rs deleted file mode 100644 index c314f767..00000000 --- a/src/core/adapter/kitty.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::io::Write; - -use anyhow::Result; -use base64::{engine::general_purpose, Engine}; -use image::DynamicImage; - -pub struct Kitty; - -impl Kitty { - pub fn image_show(img: DynamicImage) -> Result> { - fn output(raw: Vec, format: u8, size: (u32, u32)) -> Result> { - let b64 = general_purpose::STANDARD.encode(raw).chars().collect::>(); - - let mut it = b64.chunks(4096).peekable(); - let mut buf = Vec::with_capacity(b64.len() + it.len() * 50); - if let Some(first) = it.next() { - write!( - buf, - "\x1b_Ga=d\x1b\\\x1b_Ga=T,f={},s={},v={},m={};{}\x1b\\", - format, - size.0, - size.1, - it.peek().is_some() as u8, - first.iter().collect::(), - )?; - } - - while let Some(chunk) = it.next() { - write!( - buf, - "\x1b_Gm={};{}\x1b\\", - it.peek().is_some() as u8, - chunk.iter().collect::() - )?; - } - Ok(buf) - } - - let size = (img.width(), img.height()); - 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), - } - } - - #[inline] - pub fn image_hide() -> &'static [u8; 8] { b"\x1b_Ga=d\x1b\\" } -} diff --git a/src/core/adapter/mod.rs b/src/core/adapter/mod.rs deleted file mode 100644 index 092bf152..00000000 --- a/src/core/adapter/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod kitty; - -pub use kitty::*; diff --git a/src/core/adaptor/adaptor.rs b/src/core/adaptor/adaptor.rs new file mode 100644 index 00000000..141a4aa2 --- /dev/null +++ b/src/core/adaptor/adaptor.rs @@ -0,0 +1,53 @@ +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}; + +static IMAGE_SHOWN: AtomicBool = AtomicBool::new(false); + +static UEBERZUG: Lazy>>> = + Lazy::new(|| if PREVIEW.adaptor.needs_ueberzug() { Ueberzug::start().ok() } else { None }); + +pub struct Adaptor; + +impl Adaptor { + pub fn init() { Lazy::force(&UEBERZUG); } + + pub async fn image_show(path: &Path, rect: Rect) -> Result<()> { + if IMAGE_SHOWN.swap(true, Ordering::Relaxed) { + Self::image_hide(rect); + } + + match PREVIEW.adaptor { + PreviewAdaptor::Kitty => Kitty::image_show(path, rect).await, + PreviewAdaptor::Iterm2 => Iterm2::image_show(path, rect).await, + _ => { + if let Some(tx) = &*UEBERZUG { + tx.send(Some((path.to_path_buf(), rect))).ok(); + } + Ok(()) + } + } + } + + pub fn image_hide(rect: Rect) { + if !IMAGE_SHOWN.swap(false, Ordering::Relaxed) { + return; + } + + match PREVIEW.adaptor { + PreviewAdaptor::Kitty => Kitty::image_hide(), + PreviewAdaptor::Iterm2 => Iterm2::image_hide(rect), + _ => { + if let Some(tx) = &*UEBERZUG { + tx.send(None).ok(); + } + } + } + } +} diff --git a/src/core/adaptor/image.rs b/src/core/adaptor/image.rs new file mode 100644 index 00000000..65e1b26f --- /dev/null +++ b/src/core/adaptor/image.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +use anyhow::Result; +use image::{imageops::FilterType, DynamicImage}; +use tokio::fs; + +use crate::{config::PREVIEW, misc::tty_ratio}; + +pub(super) struct Image; + +impl Image { + pub(super) async fn resize(path: &Path, size: (u16, u16)) -> Result { + let (w, h) = { + let r = tty_ratio(); + let (w, h) = ((size.0 as f64 * r.0) as u32, (size.1 as f64 * r.1) as u32); + (w.min(PREVIEW.max_width), h.min(PREVIEW.max_height)) + }; + + let img = fs::read(path).await?; + let img = tokio::task::spawn_blocking(move || -> Result { + let img = image::load_from_memory(&img)?; + Ok(if img.width() > w || img.height() > h { + img.resize(w, h, FilterType::Triangle) + } else { + img + }) + }); + + Ok(img.await??) + } +} diff --git a/src/core/adaptor/iterm2.rs b/src/core/adaptor/iterm2.rs new file mode 100644 index 00000000..7200461a --- /dev/null +++ b/src/core/adaptor/iterm2.rs @@ -0,0 +1,53 @@ +use std::{io::Write, path::Path}; + +use anyhow::Result; +use base64::{engine::general_purpose, Engine}; +use image::{codecs::jpeg::JpegEncoder, DynamicImage}; +use ratatui::prelude::Rect; +use tokio::io::AsyncWriteExt; + +use super::image::Image; +use crate::ui::Term; + +pub(super) struct Iterm2; + +impl Iterm2 { + pub(super) async fn image_show(path: &Path, rect: Rect) -> Result<()> { + let img = Image::resize(path, (rect.width, rect.height)).await?; + let b = Self::encode(img).await?; + + Term::move_to(rect.x, rect.y).ok(); + tokio::io::stdout().write_all(&b).await.ok(); + Ok(()) + } + + #[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:{}\x07", + jpg.len(), + size.0, + size.1, + general_purpose::STANDARD.encode(&jpg) + )?; + Ok(buf) + }) + .await? + } +} diff --git a/src/core/adaptor/kitty.rs b/src/core/adaptor/kitty.rs new file mode 100644 index 00000000..57ed456c --- /dev/null +++ b/src/core/adaptor/kitty.rs @@ -0,0 +1,64 @@ +use std::{io::Write, path::Path}; + +use anyhow::Result; +use base64::{engine::general_purpose, Engine}; +use image::DynamicImage; +use ratatui::prelude::Rect; +use tokio::io::AsyncWriteExt; + +use super::image::Image; +use crate::ui::Term; + +pub(super) struct Kitty; + +impl Kitty { + pub(super) async fn image_show(path: &Path, rect: Rect) -> Result<()> { + let img = Image::resize(path, (rect.width, rect.height)).await?; + let b = Self::encode(img).await?; + + Term::move_to(rect.x, rect.y).ok(); + tokio::io::stdout().write_all(&b).await.ok(); + Ok(()) + } + + #[inline] + 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: &[u8], format: u8, size: (u32, u32)) -> Result> { + let b64 = general_purpose::STANDARD.encode(raw).chars().collect::>(); + + let mut it = b64.chunks(4096).peekable(); + let mut buf = Vec::with_capacity(b64.len() + it.len() * 50); + if let Some(first) = it.next() { + write!( + buf, + "\x1b\\\x1b_Ga=d\x1b\\\x1b_Ga=T,f={},s={},v={},m={};{}\x1b\\", + format, + size.0, + size.1, + it.peek().is_some() as u8, + first.iter().collect::(), + )?; + } + + while let Some(chunk) = it.next() { + write!( + buf, + "\x1b_Gm={};{}\x1b\\", + it.peek().is_some() as u8, + chunk.iter().collect::() + )?; + } + Ok(buf) + } + + let size = (img.width(), img.height()); + tokio::task::spawn_blocking(move || match img { + 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/mod.rs b/src/core/adaptor/mod.rs new file mode 100644 index 00000000..1c99201e --- /dev/null +++ b/src/core/adaptor/mod.rs @@ -0,0 +1,7 @@ +mod adaptor; +mod image; +mod iterm2; +mod kitty; +mod ueberzug; + +pub use adaptor::*; diff --git a/src/core/adaptor/ueberzug.rs b/src/core/adaptor/ueberzug.rs new file mode 100644 index 00000000..bca5c1aa --- /dev/null +++ b/src/core/adaptor/ueberzug.rs @@ -0,0 +1,65 @@ +use std::{path::PathBuf, process::Stdio}; + +use anyhow::Result; +use ratatui::prelude::Rect; +use tokio::{io::AsyncWriteExt, process::{Child, Command}, sync::mpsc::{self, UnboundedSender}}; + +use crate::config::PREVIEW; + +pub(super) struct Ueberzug; + +impl Ueberzug { + pub(super) fn start() -> Result>> { + let mut child = Self::create_demon().ok(); + let (tx, mut rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + 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; + } + if child.is_none() { + child = Self::create_demon().ok(); + } + if let Some(c) = &mut child { + Self::send_command(c, cmd).await.ok(); + } + } + }); + + Ok(tx) + } + + fn create_demon() -> Result { + Ok( + Command::new("ueberzug") + .args(["layer", "-so", &PREVIEW.adaptor.to_string()]) + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?, + ) + } + + async fn send_command(child: &mut Child, cmd: Option<(PathBuf, Rect)>) -> Result<()> { + let stdin = child.stdin.as_mut().unwrap(); + 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, + rect.width, + rect.height, + path.to_string_lossy(), + "\n" + ); + stdin.write_all(s.as_bytes()).await?; + } else { + stdin + .write_all(format!(r#"{{"action":"remove","identifier":"yazi"}}{}"#, "\n").as_bytes()) + .await?; + } + Ok(()) + } +} diff --git a/src/core/event.rs b/src/core/event.rs index 0c50ef80..b9b65f07 100644 --- a/src/core/event.rs +++ b/src/core/event.rs @@ -25,7 +25,7 @@ pub enum Event { Pages(usize), Mimetype(BTreeMap), Hover(Option), - Preview(PathBuf, PreviewData), + Preview(PathBuf, String, PreviewData), // Input Select(SelectOpt, oneshot::Sender>), @@ -94,8 +94,8 @@ macro_rules! emit { (Hover($file:expr)) => { $crate::core::Event::Hover(Some($file)).emit(); }; - (Preview($path:expr, $data:expr)) => { - $crate::core::Event::Preview($path, $data).emit(); + (Preview($path:expr, $mime:expr, $data:expr)) => { + $crate::core::Event::Preview($path, $mime, $data).emit(); }; (Select($opt:expr)) => {{ diff --git a/src/core/manager/manager.rs b/src/core/manager/manager.rs index dba8db8c..434d113e 100644 --- a/src/core/manager/manager.rs +++ b/src/core/manager/manager.rs @@ -49,20 +49,24 @@ impl Manager { self.watcher.watch(to_watch); } - pub fn preview(&mut self) -> bool { + pub fn preview(&mut self, show_image: bool) -> bool { let hovered = if let Some(h) = self.hovered() { h.clone() } else { return self.active_mut().preview.reset(); }; + if !show_image { + self.active_mut().preview.reset_image(); + } + if hovered.meta.is_dir() { - self.active_mut().preview.go(&hovered.path, MIME_DIR); + self.active_mut().preview.go(&hovered.path, MIME_DIR, show_image); if self.active().history(&hovered.path).is_some() { - emit!(Preview(hovered.path, PreviewData::Folder)); + emit!(Preview(hovered.path, MIME_DIR.to_owned(), PreviewData::Folder)); } } else if let Some(mime) = self.mimetype.get(&hovered.path).cloned() { - self.active_mut().preview.go(&hovered.path, &mime); + self.active_mut().preview.go(&hovered.path, &mime, show_image); } else { tokio::spawn(async move { if let Ok(mimes) = external::file(&[hovered.path]).await { @@ -300,11 +304,10 @@ impl Manager { tasks.precache_video(&mimes); self.mimetype.extend(mimes); - self.preview(); true } - pub fn update_preview(&mut self, path: PathBuf, data: PreviewData) -> bool { + pub fn update_preview(&mut self, path: PathBuf, mime: String, data: PreviewData) -> bool { let hovered = if let Some(ref h) = self.current().hovered { h.path() } else { @@ -316,7 +319,7 @@ impl Manager { } let preview = &mut self.active_mut().preview; - preview.path = path; + preview.lock = Some((path, mime)); preview.data = data; true } diff --git a/src/core/manager/mod.rs b/src/core/manager/mod.rs index e0074f07..da18f7bc 100644 --- a/src/core/manager/mod.rs +++ b/src/core/manager/mod.rs @@ -22,4 +22,4 @@ pub const ALL_RATIO: u32 = PARENT_RATIO + CURRENT_RATIO + PREVIEW_RATIO; pub const DIR_PADDING: u16 = 2; pub const PREVIEW_BORDER: u16 = 2; -pub const PREVIEW_PADDING: u16 = 2; +pub const PREVIEW_MARGIN: u16 = 2; diff --git a/src/core/manager/preview.rs b/src/core/manager/preview.rs index 8a0b5153..8294f209 100644 --- a/src/core/manager/preview.rs +++ b/src/core/manager/preview.rs @@ -1,21 +1,22 @@ -use std::{fs::File, io::{BufRead, BufReader}, path::{Path, PathBuf}, sync::OnceLock}; +use std::{fs::File, io::{BufRead, BufReader}, mem, path::{Path, PathBuf}, sync::OnceLock}; use anyhow::{anyhow, Result}; -use image::imageops::FilterType; +use ratatui::prelude::Rect; use syntect::{easy::HighlightFile, highlighting::{Theme, ThemeSet}, parsing::SyntaxSet, util::as_24_bit_terminal_escaped}; use tokio::{fs, task::JoinHandle}; -use super::{ALL_RATIO, PREVIEW_BORDER, PREVIEW_PADDING, PREVIEW_RATIO}; -use crate::{config::{PREVIEW, THEME}, core::{adapter::Kitty, external, files::{Files, FilesOp}, tasks::Precache}, emit, misc::{tty_ratio, tty_size, MimeKind}}; +use super::{ALL_RATIO, CURRENT_RATIO, PARENT_RATIO, PREVIEW_BORDER, PREVIEW_MARGIN, PREVIEW_RATIO}; +use crate::{config::{PREVIEW, THEME}, core::{adaptor::Adaptor, external, files::{Files, FilesOp}, tasks::Precache}, emit, misc::{tty_size, MimeKind}}; static SYNTECT_SYNTAX: OnceLock = OnceLock::new(); static SYNTECT_THEME: OnceLock = OnceLock::new(); -#[derive(Debug)] +#[derive(Default)] pub struct Preview { - pub path: PathBuf, + pub lock: Option<(PathBuf, String)>, pub data: PreviewData, - handle: Option>, + + handle: Option>, } #[derive(Debug, Default)] @@ -24,49 +25,70 @@ pub enum PreviewData { None, Folder, Text(String), - Image(Vec), + Image, } impl Preview { - pub fn new() -> Self { - Self { path: Default::default(), data: Default::default(), handle: Default::default() } - } - - fn size() -> (u16, u16) { + fn rect() -> Rect { let s = tty_size(); - let col = (s.ws_col as u32 * PREVIEW_RATIO / ALL_RATIO) as u16; - (col.saturating_sub(PREVIEW_BORDER), s.ws_row.saturating_sub(PREVIEW_PADDING)) + + let x = (s.ws_col as u32 * (PARENT_RATIO + CURRENT_RATIO) / ALL_RATIO) as u16; + let width = (s.ws_col as u32 * PREVIEW_RATIO / ALL_RATIO) as u16; + + Rect { + x: x.saturating_add(PREVIEW_BORDER / 2), + y: PREVIEW_MARGIN / 2, + width: width.saturating_sub(PREVIEW_BORDER), + height: s.ws_row.saturating_sub(PREVIEW_MARGIN), + } } - pub fn go(&mut self, path: &Path, mime: &str) { - if let Some(handle) = self.handle.take() { - handle.abort(); + pub fn go(&mut self, path: &Path, mime: &str, show_image: bool) { + let kind = MimeKind::new(&mime); + if !show_image && matches!(kind, MimeKind::Image | MimeKind::Video) { + return; + } else if self.same(path, mime) { + return; + } else { + self.reset(); } let (path, mime) = (path.to_path_buf(), mime.to_owned()); self.handle = Some(tokio::spawn(async move { - let result = match MimeKind::new(&mime) { + let result = match kind { 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(&path).await.map(PreviewData::Image), - MimeKind::Video => Self::video(&path).await.map(PreviewData::Image), + 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)), }; - emit!(Preview(path, result.unwrap_or_default())); + emit!(Preview(path, mime, result.unwrap_or_default())); })); } pub fn reset(&mut self) -> bool { - if self.path == PathBuf::default() { - return false; - } + self.handle.take().map(|h| h.abort()); + Adaptor::image_hide(Self::rect()); - self.path = Default::default(); - self.data = Default::default(); - true + self.lock = None; + !matches!( + mem::replace(&mut self.data, PreviewData::None), + PreviewData::None | PreviewData::Image + ) + } + + pub fn reset_image(&mut self) -> bool { + self.handle.take().map(|h| h.abort()); + Adaptor::image_hide(Self::rect()); + + if matches!(self.data, PreviewData::Image) { + self.lock = None; + self.data = PreviewData::None; + } + false } pub async fn folder(path: &Path) -> Result { @@ -78,32 +100,17 @@ impl Preview { Ok(PreviewData::Folder) } - pub async fn image(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(); } - let (w, h) = { - let r = tty_ratio(); - let (w, h) = Self::size(); - let (w, h) = ((w as f64 * r.0) as u32, (h as f64 * r.1) as u32); - (w.min(PREVIEW.max_width), h.min(PREVIEW.max_height)) - }; - - let img = fs::read(path).await?; - tokio::task::spawn_blocking(move || -> Result> { - let img = image::load_from_memory(&img)?; - Kitty::image_show(if img.width() > w || img.height() > h { - img.resize(w, h, FilterType::Triangle) - } else { - img - }) - }) - .await? + Adaptor::image_show(path, Self::rect()).await?; + Ok(PreviewData::Image) } - pub async fn video(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?; @@ -117,7 +124,7 @@ impl Preview { external::jq(path) .await? .lines() - .take(Self::size().1 as usize) + .take(Self::rect().height as usize) .collect::>() .join("\n"), ) @@ -128,7 +135,7 @@ impl Preview { external::lsar(path) .await? .into_iter() - .take(Self::size().1 as usize) + .take(Self::rect().height as usize) .map(|f| f.name) .collect::>() .join("\n"), @@ -153,7 +160,7 @@ impl Preview { let mut line = String::new(); let mut buf = String::new(); - let mut i = Self::size().1 as usize; + let mut i = Self::rect().height as usize; while i > 0 && h.reader.read_line(&mut line)? > 0 { i -= 1; line = line.replace('\t', &spaces); @@ -168,3 +175,15 @@ impl Preview { .await? } } + +impl Preview { + #[inline] + pub fn same(&self, path: &Path, mime: &str) -> bool { + self.lock.as_ref().map(|(p, m)| p == path && m == mime).unwrap_or(false) + } + + #[inline] + pub fn same_path(&self, path: &Path) -> bool { + self.lock.as_ref().map(|(p, _)| p == path).unwrap_or(false) + } +} diff --git a/src/core/manager/tab.rs b/src/core/manager/tab.rs index d7c9dde9..796d8d07 100644 --- a/src/core/manager/tab.rs +++ b/src/core/manager/tab.rs @@ -27,7 +27,7 @@ impl Tab { search: None, history: Default::default(), - preview: Preview::new(), + preview: Default::default(), } } diff --git a/src/core/mod.rs b/src/core/mod.rs index c01cc426..f2843f2d 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,4 @@ -pub mod adapter; +pub mod adaptor; mod blocker; mod event; pub mod external; diff --git a/src/core/tasks/tasks.rs b/src/core/tasks/tasks.rs index 4507008b..13941e94 100644 --- a/src/core/tasks/tasks.rs +++ b/src/core/tasks/tasks.rs @@ -67,6 +67,7 @@ impl Tasks { pub fn toggle(&mut self) -> bool { self.visible = !self.visible; + emit!(Hover); // Show/hide preview for images true } diff --git a/src/core/which/which.rs b/src/core/which/which.rs index dd063d0c..b50add1f 100644 --- a/src/core/which/which.rs +++ b/src/core/which/which.rs @@ -26,7 +26,7 @@ impl Which { .filter(|s| s.on.len() > 1 && s.on[0] == *key) .cloned() .collect(); - self.visible = true; + self.switch(true); true } @@ -37,16 +37,22 @@ impl Which { .collect(); if self.cands.is_empty() { - self.visible = false; + self.switch(false); } else if self.cands.len() == 1 { - self.visible = false; + self.switch(false); emit!(Ctrl(self.cands.remove(0), self.layer)); } else if let Some(i) = self.cands.iter().position(|c| c.on.len() == self.times + 1) { + self.switch(false); emit!(Ctrl(self.cands.remove(i), self.layer)); - self.visible = false; } self.times += 1; return true; } + + #[inline] + fn switch(&mut self, state: bool) { + self.visible = state; + emit!(Hover); // Show/hide preview for images + } } 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/app.rs b/src/ui/app.rs index c9b6b450..3a8ef940 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -55,7 +55,7 @@ impl App { let _ = term.draw(|f| { f.render_widget(Root::new(&mut self.cx), f.size()); - if let Some((x, y)) = self.cx.cursor { + if let Some((x, y)) = self.cx.cursor() { f.set_cursor(x, y); } }); @@ -64,7 +64,7 @@ impl App { fn dispatch_resize(&mut self) { self.cx.manager.current_mut().set_page(true); - self.cx.manager.preview(); + self.cx.manager.preview(self.cx.image_layer()); emit!(Render); } @@ -123,17 +123,17 @@ impl App { Event::Mimetype(mimes) => { if manager.update_mimetype(mimes, tasks) { emit!(Render); + self.cx.manager.preview(self.cx.image_layer()); } } Event::Hover(file) => { - let mut b = file.map(|f| manager.current_mut().hover_force(f)).unwrap_or(false); - b |= manager.preview(); - if b { + if file.map(|f| manager.current_mut().hover_force(f)).unwrap_or(false) { emit!(Render); } + self.cx.manager.preview(self.cx.image_layer()); } - Event::Preview(file, data) => { - manager.update_preview(file, data); + Event::Preview(path, mime, data) => { + manager.update_preview(path, mime, data); emit!(Render); } diff --git a/src/ui/context.rs b/src/ui/context.rs index be8e035b..0c18dace 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -1,8 +1,6 @@ use crate::{config::keymap::KeymapLayer, core::{input::Input, manager::Manager, select::Select, tasks::Tasks, which::Which, Position}, misc::tty_size}; pub struct Ctx { - pub cursor: Option<(u16, u16)>, - pub manager: Manager, pub which: Which, pub select: Select, @@ -13,8 +11,6 @@ pub struct Ctx { impl Ctx { pub fn new() -> Self { Self { - cursor: None, - manager: Manager::new(), which: Default::default(), select: Default::default(), @@ -23,6 +19,14 @@ impl Ctx { } } + #[inline] + pub fn cursor(&self) -> Option<(u16, u16)> { + if self.input.visible { + return Some(self.input.cursor()); + } + None + } + #[inline] pub fn layer(&self) -> KeymapLayer { if self.which.visible { @@ -38,6 +42,15 @@ impl Ctx { } } + #[inline] + pub fn image_layer(&self) -> bool { + match self.layer() { + KeymapLayer::Which => false, + KeymapLayer::Tasks => false, + _ => true, + } + } + pub fn position(&self, pos: Position) -> Position { match pos { Position::Top => Position::Coords((tty_size().ws_col / 2).saturating_sub(25), 2), diff --git a/src/ui/manager/preview.rs b/src/ui/manager/preview.rs index f20eddd1..137b51ec 100644 --- a/src/ui/manager/preview.rs +++ b/src/ui/manager/preview.rs @@ -1,10 +1,8 @@ -use std::io::{stdout, Write}; - use ansi_to_tui::IntoText; -use ratatui::{buffer::Buffer, layout::Rect, widgets::{Paragraph, Widget}}; +use ratatui::{buffer::Buffer, layout::Rect, widgets::{Clear, Paragraph, Widget}}; use super::Folder; -use crate::{core::{adapter::Kitty, manager::PreviewData}, ui::{Ctx, Term}}; +use crate::{core::manager::{PreviewData, PREVIEW_BORDER}, ui::Ctx}; pub struct Preview<'a> { cx: &'a Ctx, @@ -16,32 +14,32 @@ impl<'a> Preview<'a> { impl<'a> Widget for Preview<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - if self.cx.input.visible || self.cx.select.visible || self.cx.tasks.visible { - stdout().write(Kitty::image_hide()).ok(); - return; - } + Clear.render( + Rect { + x: area.x, + y: area.y, + width: area.width + PREVIEW_BORDER / 2, + height: area.height, + }, + buf, + ); let manager = &self.cx.manager; let hovered = if let Some(h) = manager.hovered() { - h.clone() + h.path() } else { - stdout().write(Kitty::image_hide()).ok(); return; }; let preview = manager.active().preview(); - if preview.path != hovered.path { + if !preview.same_path(&hovered) { return; } - if !matches!(preview.data, PreviewData::Image(_)) { - stdout().write(Kitty::image_hide()).ok(); - } - match &preview.data { PreviewData::None => {} PreviewData::Folder => { - if let Some(folder) = manager.active().history(&hovered.path) { + if let Some(folder) = manager.active().history(&hovered) { Folder::new(self.cx, folder).with_preview(true).render(area, buf); } } @@ -49,10 +47,7 @@ impl<'a> Widget for Preview<'a> { let p = Paragraph::new(s.as_bytes().into_text().unwrap()); p.render(area, buf); } - PreviewData::Image(b) => { - Term::move_to(area.x, area.y).ok(); - stdout().write(b).ok(); - } + PreviewData::Image => {} } } } diff --git a/src/ui/root.rs b/src/ui/root.rs index 43227c72..c202aa2f 100644 --- a/src/ui/root.rs +++ b/src/ui/root.rs @@ -3,11 +3,11 @@ use ratatui::{buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, wid use super::{header, manager, status, tasks, which::Which, Ctx, Input, Select}; pub struct Root<'a> { - cx: &'a mut Ctx, + cx: &'a Ctx, } impl<'a> Root<'a> { - pub fn new(cx: &'a mut Ctx) -> Self { Self { cx } } + pub fn new(cx: &'a Ctx) -> Self { Self { cx } } } impl<'a> Widget for Root<'a> { @@ -31,9 +31,6 @@ impl<'a> Widget for Root<'a> { if self.cx.input.visible { Input::new(self.cx).render(area, buf); - self.cx.cursor = Some(self.cx.input.cursor()); - } else { - self.cx.cursor = None; } if self.cx.which.visible { diff --git a/src/ui/which/layout.rs b/src/ui/which/layout.rs index 4653564d..285ac8d1 100644 --- a/src/ui/which/layout.rs +++ b/src/ui/which/layout.rs @@ -4,11 +4,11 @@ use super::Side; use crate::ui::Ctx; pub struct Which<'a> { - cx: &'a mut Ctx, + cx: &'a Ctx, } impl<'a> Which<'a> { - pub fn new(cx: &'a mut Ctx) -> Self { Self { cx } } + pub fn new(cx: &'a Ctx) -> Self { Self { cx } } } impl Widget for Which<'_> {