use ansi_to_tui::IntoText;
use color_eyre::eyre::{Result, eyre};
use portable_pty::{PtyPair, PtySize, native_pty_system};
use ratatui::layout::Alignment;
use ratatui::prelude::Backend;
use ratatui::style::Stylize;
use ratatui::text::{Line, Text};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use ratatui_image::picker::Picker;
use ratatui_image::protocol::Protocol as ImageProtocol;
use tui_term::vt100;
use tui_term::widget::PseudoTerminal;
use std::env;
use std::io::Read;
use std::sync::{Arc, RwLock, mpsc};
use std::thread::JoinHandle;
use std::time::Instant;
use super::statusline::spinner_char;
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
use super::widget::{SkimRender, SkimWidget};
use super::{BorderType, Direction, Event, Tui};
use crate::theme::ColorTheme;
use crate::{SkimItem, SkimOptions};
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
const VT_SCROLLBACK: usize = 100_000;
pub(crate) enum PreviewContent {
Text(Text<'static>),
Terminal(Arc<RwLock<vt100::Parser>>),
Image {
source: image::DynamicImage,
protocol: Option<ImageProtocol>,
size: ratatui::layout::Size,
},
}
impl Default for PreviewContent {
fn default() -> Self {
PreviewContent::Text(Text::default())
}
}
#[derive(Clone)]
pub struct PreviewCallback {
inner: Arc<PreviewCallbackFn>,
}
impl<F> From<F> for PreviewCallback
where
F: Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static,
{
fn from(func: F) -> Self {
Self { inner: Arc::new(func) }
}
}
impl std::ops::Deref for PreviewCallback {
type Target = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
fn deref(&self) -> &Self::Target {
&*self.inner
}
}
pub struct Preview {
pub(crate) content: Arc<RwLock<PreviewContent>>,
pub cmd: String,
pub rows: u16,
pub cols: u16,
pub scroll_y: u16,
pub scroll_x: u16,
pub thread_handle: Option<JoinHandle<()>>,
interrupt_tx: Option<mpsc::Sender<()>>,
pub theme: Arc<ColorTheme>,
pub border: BorderType,
pub direction: Direction,
pub wrap: bool,
pty: Option<PtyPair>,
pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
image: bool,
image_picker: Option<Picker>,
pub total_lines: u16,
loading: bool,
spinner_start: Instant,
}
impl Default for Preview {
fn default() -> Self {
Self::_default()
}
}
impl Preview {
fn image_protocol(
picker: Option<&Picker>,
source: image::DynamicImage,
size: ratatui::layout::Size,
) -> std::result::Result<ImageProtocol, ratatui_image::errors::Errors> {
let fallback;
let picker = if let Some(picker) = picker {
picker
} else {
fallback = Picker::halfblocks();
&fallback
};
let font_size = picker.font_size();
let max_pixel_width = u128::from(size.width) * u128::from(font_size.width);
let max_pixel_height = u128::from(size.height) * u128::from(font_size.height);
let image_width = u128::from(source.width());
let image_height = u128::from(source.height());
let size = if image_height * max_pixel_width <= max_pixel_height * image_width {
let pixel_height = image_height * max_pixel_width / image_width;
let height = pixel_height.div_ceil(u128::from(font_size.height));
ratatui::layout::Size::new(size.width, u16::try_from(height).unwrap_or(u16::MAX).max(1))
} else {
let pixel_width = image_width * max_pixel_height / image_height;
let width = pixel_width.div_ceil(u128::from(font_size.width));
ratatui::layout::Size::new(u16::try_from(width).unwrap_or(u16::MAX).max(1), size.height)
};
picker.new_protocol(source, size, ratatui_image::Resize::Scale(None))
}
pub(crate) fn set_image_picker(&mut self, picker: Option<Picker>) {
self.image_picker = picker;
}
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 {
match size {
super::Size::Fixed(n) => n,
super::Size::Percent(p) => {
let dimension = if is_vertical { self.rows } else { self.cols };
u16::try_from(u32::from(dimension) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
super::Size::Neg(n) => {
let dimension = if is_vertical { self.rows } else { self.cols };
dimension.saturating_sub(n)
}
}
}
fn init_pty(&mut self) {
let pty_system = native_pty_system();
let cols = if self.wrap { self.cols } else { 1024 };
let pair = pty_system.openpty(PtySize {
rows: self.rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
match pair {
Ok(p) => self.pty = Some(p),
Err(e) => warn!("failed to init preview pty: {e:?}"),
}
}
fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
let mut result = Vec::new();
let mut i = 0;
while i < data.len() {
if data[i] == b'\x1b' && i + 1 < data.len() {
match data[i + 1] {
b']' => {
if let Some(end) = find_osc_end(&data[i..]) {
let seq = &data[i..i + end];
handle_osc_query(seq, writer);
i += end;
continue;
}
}
b'[' => {
if let Some(end) = find_csi_end(&data[i..]) {
let seq = &data[i..i + end];
if handle_csi_query(seq, writer) {
i += end;
continue;
}
}
}
_ => {}
}
}
result.push(data[i]);
i += 1;
}
result
}
pub fn content(&mut self, content: &[u8]) -> Result<()> {
let text = content.to_owned().into_text()?;
let Ok(mut content) = self.content.write() else {
return Err(color_eyre::eyre::eyre!("Failed to acquire content for writing"));
};
self.total_lines = text.lines.len().try_into().unwrap();
*content = PreviewContent::Text(text);
self.scroll_y = 0;
self.scroll_x = 0;
self.loading = false;
Ok(())
}
pub(crate) fn is_loading(&self) -> bool {
self.loading
}
pub(crate) fn mark_ready(&mut self) {
self.loading = false;
}
pub fn content_with_position(&mut self, content: &[u8], position: crate::PreviewPosition) -> Result<()> {
self.content(content).map(|()| {
let v_scroll = self.size_to_offset(position.v_scroll, true);
let v_offset = self.size_to_offset(position.v_offset, true);
self.scroll_y = v_scroll.saturating_add(v_offset);
let h_scroll = self.size_to_offset(position.h_scroll, false);
let h_offset = self.size_to_offset(position.h_offset, false);
self.scroll_x = h_scroll.saturating_add(h_offset);
})
}
pub fn scroll_up(&mut self, lines: u16) {
self.scroll_y = self.scroll_y.saturating_sub(lines);
}
pub fn scroll_down(&mut self, lines: u16) {
trace!(
"scrolling down by {lines} lines, ({} total, {} rows)",
self.total_lines, self.rows
);
if self.total_lines > 0 {
self.scroll_y = self
.scroll_y
.saturating_add(lines)
.min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
} else {
self.scroll_y = self.scroll_y.saturating_add(lines);
}
}
pub fn scroll_left(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_add(cols);
}
pub fn set_offset(&mut self, offset: u16) {
self.scroll_y = offset.saturating_sub(1); }
pub fn page_up(&mut self) {
let page_size = self.rows.saturating_sub(2); self.scroll_up(page_size);
}
pub fn page_down(&mut self) {
let page_size = self.rows.saturating_sub(2); self.scroll_down(page_size);
}
pub fn kill(&mut self) {
if let Some(tx) = self.interrupt_tx.take() {
let _ = tx.send(());
}
if let Some(mut child) = self.pty_child.take() {
trace!("killing pty child process");
match child.try_wait() {
Ok(Some(status)) => {
trace!("child already exited with status: {status:?}");
}
Ok(None) => {
trace!("child still running, sending kill signal");
if let Err(e) = child.kill() {
trace!("failed to kill pty child: {e:?}");
}
}
Err(e) => {
debug!("error checking child status: {e:?}");
let _ = child.kill();
}
}
}
}
#[allow(clippy::too_many_lines)]
pub fn spawn<B: Backend>(&mut self, tui: &mut Tui<B>, cmd: &str) -> Result<()>
where
B::Error: Send + Sync + 'static,
{
self.kill();
self.cmd = cmd.to_string();
self.loading = true;
self.scroll_y = 0;
self.scroll_x = 0;
let event_tx_clone = tui.event_tx.clone();
let content = self.content.clone();
if self.image {
let cmd = self.cmd.clone();
let (interrupt_tx, interrupt_rx) = mpsc::channel();
self.interrupt_tx = Some(interrupt_tx);
self.thread_handle = Some(std::thread::spawn(move || {
let res: PreviewContent;
if let Ok(Ok(decoded)) = image::ImageReader::open(&cmd).map(image::ImageReader::decode) {
res = PreviewContent::Image {
source: decoded,
protocol: None,
size: ratatui::layout::Size::default(),
};
} else {
res = PreviewContent::Text(Text::raw(format!("Failed to open {cmd} as image")));
}
if interrupt_rx.try_recv().is_ok() {
trace!("interrupt signal received, exiting");
return;
}
if let Ok(mut c) = content.write() {
*c = res;
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
}
}));
return Ok(());
}
if let Some(pty) = self.pty.take() {
if self.rows > 0 && self.cols > 0 {
let _ = pty.master.resize(PtySize {
rows: self.rows,
cols: self.cols,
pixel_width: 0,
pixel_height: 0,
});
}
trace!(
"spawning preview cmd {cmd} in pty (rows={}, cols={})",
self.rows, self.cols
);
self.init_pty();
trace!("initialized pty");
let mut shell_cmd = portable_pty::CommandBuilder::new("/bin/sh");
shell_cmd.env("ROWS", self.rows.to_string());
shell_cmd.env("COLUMNS", self.cols.to_string());
shell_cmd.env("PAGER", "");
shell_cmd.arg("-c");
if let Ok(cwd) = env::current_dir() {
shell_cmd.cwd(cwd);
}
shell_cmd.arg(cmd);
self.pty_child = Some(pty.slave.spawn_command(shell_cmd).map_err(|e| {
warn!("{:#?}", e.backtrace());
eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e))
})?);
let mut reader = pty
.master
.try_clone_reader()
.map_err(|e| eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)))?;
let mut esc_writer = pty
.master
.take_writer()
.map_err(|e| eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)))?;
let (interrupt_tx, interrupt_rx) = mpsc::channel();
self.interrupt_tx = Some(interrupt_tx);
let cols = if self.wrap { self.cols } else { 1024 };
let parser = Arc::new(RwLock::new(vt100::Parser::new(self.rows, cols, VT_SCROLLBACK)));
if let Ok(mut c) = content.write() {
*c = PreviewContent::Terminal(parser.clone());
}
self.thread_handle = Some(std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut unprocessed_buf = Vec::new();
trace!("preview reader thread started");
loop {
if interrupt_rx.try_recv().is_ok() {
trace!("interrupt signal received, exiting");
return;
}
match reader.read(&mut buf) {
Ok(0) => {
trace!("reached EOF");
break;
}
Ok(size) => {
trace!("read {size} bytes");
unprocessed_buf.extend_from_slice(&buf[..size]);
let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
if let Ok(mut parser_guard) = parser.write() {
parser_guard.process(&filtered);
parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
}
unprocessed_buf.clear();
if interrupt_rx.try_recv().is_ok() {
trace!("interrupt signal received during read block, exiting");
return;
}
}
Err(e) => {
trace!("read error {e:?}");
break;
}
}
}
if interrupt_rx.try_recv().is_ok() {
trace!("interrupt signal received, exiting");
return;
}
trace!("read complete");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
}));
} else {
trace!("spawning preview cmd {cmd}");
let mut shell_cmd = crate::shell_cmd(cmd);
shell_cmd
.env("ROWS", self.rows.to_string())
.env("COLUMNS", self.cols.to_string())
.env("PAGER", "");
if let Ok(cwd) = env::current_dir() {
shell_cmd.current_dir(cwd);
}
let (interrupt_tx, interrupt_rx) = mpsc::channel();
self.interrupt_tx = Some(interrupt_tx);
self.thread_handle = Some(std::thread::spawn(move || {
if interrupt_rx.try_recv().is_ok() {
return;
}
let try_out = shell_cmd.output();
if try_out.is_err() {
log::info!("Shell cmd in error: {try_out:?}");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
return;
}
let mut out = try_out.unwrap();
if interrupt_rx.try_recv().is_ok() {
return;
}
if let Ok(mut c) = content.write() {
if out.status.success() {
out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
*c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
} else {
*c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
}
}
trace!("sending ready ping");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
}));
}
Ok(())
}
fn render_text(
&self,
mut outer: Block,
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
text: &Text,
) -> u16 {
let total_lines: u16 = text.lines.len().try_into().unwrap();
let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
if self.wrap {
paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false });
}
if self.scroll_y > 0 && total_lines > 0 {
let current_line = (self.scroll_y + 1) as usize; let title = format!("{current_line}/{total_lines}");
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
}
paragraph = paragraph.block(outer);
paragraph.render(area, buf);
total_lines
}
fn render_pty(
&self,
mut outer: Block,
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
parser: &std::sync::RwLock<tui_term::vt100::Parser>,
) -> u16 {
let mut total_lines = 0u16;
if let Ok(mut parser_guard) = parser.try_write() {
let scrollback_len = parser_guard.screen().scrollback();
parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
.try_into()
.unwrap();
if self.scroll_y > 0 {
trace!("scrolling in vt buffer: {}/{}", self.scroll_y, total_lines);
parser_guard
.screen_mut()
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y.into()));
}
}
if let Ok(parser_guard) = parser.try_read() {
let screen = parser_guard.screen();
if self.scroll_y > 0 && total_lines > 0 {
let title = format!("{}/{}", self.scroll_y + 1, total_lines);
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
}
let pseudo_term = PseudoTerminal::new(screen)
.cursor(tui_term::widget::Cursor::default().visibility(false))
.block(outer);
pseudo_term.render(area, buf);
}
if self.scroll_y > 0
&& let Ok(mut parser_guard) = parser.try_write()
{
parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
}
total_lines
}
fn render_image(
&self,
mut outer: Block,
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
source: &image::DynamicImage,
protocol: &mut Option<ImageProtocol>,
size: &mut ratatui::layout::Size,
) {
let title = format!("{}x{}", source.width(), source.height());
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
let inner = outer.inner(area);
outer.render(area, buf);
let image_size = ratatui::layout::Size::new(inner.width, inner.height);
if image_size.width > 0 && image_size.height > 0 && (protocol.is_none() || *size != image_size) {
match Self::image_protocol(self.image_picker.as_ref(), source.clone(), image_size) {
Ok(new_protocol) => {
*protocol = Some(new_protocol);
*size = image_size;
}
Err(err) => {
warn!("failed to render image preview: {err:?}");
}
}
}
if let Some(protocol) = protocol {
let image = ratatui_image::Image::new(protocol).allow_clipping(true);
image.render(inner, buf);
}
}
}
impl Drop for Preview {
fn drop(&mut self) {
if let Some(pty) = self.pty.take() {
let w = pty.master.take_writer();
drop(w);
}
self.kill();
}
}
impl SkimWidget for Preview {
fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
#[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
let mut res = Self {
theme,
border: options.border,
direction: options.preview_window.direction,
wrap: options.preview_window.wrap,
content: Arc::new(RwLock::new(PreviewContent::default())),
cmd: Default::default(),
rows: 0,
cols: 0,
scroll_y: 0,
scroll_x: 0,
thread_handle: None,
interrupt_tx: None,
pty: None,
pty_child: None,
image: options.image.is_some(),
image_picker: options.image_picker.clone(),
total_lines: 0,
loading: false,
spinner_start: Instant::now(),
};
#[cfg(target_os = "linux")]
if options.preview_window.pty {
res.init_pty();
}
res
}
fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
if self.rows != area.height || self.cols != area.width {
self.rows = area.height;
self.cols = area.width;
self.pty.as_ref().map(|p| {
p.master.resize(PtySize {
rows: self.rows,
cols: self.cols,
..Default::default()
})
});
}
let Ok(mut content) = self.content.try_write() else {
return SkimRender::default();
};
let mut block = Block::new().style(self.theme.normal).border_style(self.theme.border);
if let Some(border_type) = self.border.into_ratatui() {
block = block.borders(Borders::ALL).border_type(border_type);
} else {
match self.direction {
Direction::Up => block = block.borders(Borders::BOTTOM),
Direction::Down => block = block.borders(Borders::TOP),
Direction::Left => block = block.borders(Borders::RIGHT),
Direction::Right => block = block.borders(Borders::LEFT),
}
}
Clear.render(area, buf);
let spinner_area = block.inner(area);
match &mut *content {
PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
PreviewContent::Image { source, protocol, size } => {
self.render_image(block, area, buf, source, protocol, size);
}
}
if self.loading && spinner_area.width > 0 && spinner_area.height > 0 {
let x = spinner_area.x + spinner_area.width.saturating_sub(1);
let y = spinner_area.y + spinner_area.height.saturating_sub(1);
buf.set_string(x, y, spinner_char(self.spinner_start).to_string(), self.theme.spinner);
}
SkimRender::default()
}
}
#[cfg(test)]
mod tests {
use image::{DynamicImage, RgbaImage};
use ratatui::layout::Size;
use ratatui_image::picker::Picker;
use super::Preview;
fn image(width: u32, height: u32) -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::new(width, height))
}
#[test]
fn image_protocol_constrains_by_width() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(400, 200), Size::new(20, 10))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(20, 5));
}
#[test]
fn image_protocol_constrains_by_height() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(200, 400), Size::new(20, 10))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(10, 10));
}
#[test]
fn image_protocol_keeps_at_least_one_cell() {
let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(1000, 1), Size::new(1, 1))
.expect("halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(1, 1));
}
#[test]
fn image_protocol_uses_halfblocks_picker_when_none_is_provided() {
let protocol = Preview::image_protocol(None, image(400, 200), Size::new(20, 10))
.expect("fallback halfblocks protocol should be created");
assert_eq!(protocol.size(), Size::new(20, 5));
}
}