From b35304f1c35d372303a9c1564d274501c44fcb83 Mon Sep 17 00:00:00 2001 From: azy Date: Sun, 23 Feb 2025 22:40:40 +0900 Subject: [PATCH] fix: Render images as Widgets instead of paragraphs in Ratatui - Replaced paragraph-based image rendering with the Widget trait - Renamed `build_preview_paragraph` to `build_preview_widget`, returning an enum wrapper for rendering - Updated image cache to store a grid of Ratatui `Cell` instead of text - Implemented dedicated image rendering to eliminate text-based workarounds --- television/screen/preview.rs | 118 ++++++++++------ television/utils/image.rs | 261 ++++++++++++++++++++++------------- 2 files changed, 239 insertions(+), 140 deletions(-) diff --git a/television/screen/preview.rs b/television/screen/preview.rs index 4c3acce..f1a7ab9 100644 --- a/television/screen/preview.rs +++ b/television/screen/preview.rs @@ -4,13 +4,17 @@ use crate::preview::{ PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG, }; use crate::screen::colors::{Colorscheme, PreviewColorscheme}; +use crate::utils::image::ImagePreviewWidget; use crate::utils::strings::{ replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig, EMPTY_STRING, }; use anyhow::Result; use devicons::FileIcon; -use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}; +use ratatui::buffer::Buffer; +use ratatui::widgets::{ + Block, BorderType, Borders, Padding, Paragraph, Widget, Wrap, +}; use ratatui::Frame; use ratatui::{ layout::{Alignment, Rect}, @@ -22,6 +26,22 @@ use std::str::FromStr; const FILL_CHAR_SLANTED: char = '╱'; const FILL_CHAR_EMPTY: char = ' '; +pub enum PreviewWidget<'a> { + Paragraph(Paragraph<'a>), + Image(ImagePreviewWidget<'a>), +} +impl Widget for PreviewWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + match self { + PreviewWidget::Paragraph(p) => p.render(area, buf), + PreviewWidget::Image(image) => image.render(area, buf), + } + } +} + #[allow(clippy::too_many_arguments)] pub fn draw_preview_content_block( f: &mut Frame, @@ -40,7 +60,7 @@ pub fn draw_preview_content_block( )?; // render the preview content - let rp = build_preview_paragraph( + let rp = build_preview_widget( inner, &preview_state.preview.content, preview_state.target_line, @@ -51,13 +71,13 @@ pub fn draw_preview_content_block( Ok(()) } -pub fn build_preview_paragraph<'a>( +pub fn build_preview_widget<'a>( inner: Rect, preview_content: &'a PreviewContent, target_line: Option, preview_scroll: u16, colorscheme: &'a Colorscheme, -) -> Paragraph<'a> { +) -> PreviewWidget<'a> { let preview_block = Block::default().style(Style::default()).padding(Padding { top: 0, @@ -65,67 +85,79 @@ pub fn build_preview_paragraph<'a>( bottom: 0, left: 1, }); + match preview_content { - PreviewContent::AnsiText(text) => { - build_ansi_text_paragraph(text, preview_block, preview_scroll) - } - PreviewContent::PlainText(content) => build_plain_text_paragraph( - content, - preview_block, - target_line, - preview_scroll, - colorscheme.preview, + PreviewContent::AnsiText(text) => PreviewWidget::Paragraph( + build_ansi_text_paragraph(text, preview_block, preview_scroll), ), - PreviewContent::PlainTextWrapped(content) => { + PreviewContent::PlainText(content) => { + PreviewWidget::Paragraph(build_plain_text_paragraph( + content, + preview_block, + target_line, + preview_scroll, + colorscheme.preview, + )) + } + PreviewContent::PlainTextWrapped(content) => PreviewWidget::Paragraph( build_plain_text_wrapped_paragraph( content, preview_block, colorscheme.preview, ) - .scroll((preview_scroll, 0)) - } + .scroll((preview_scroll, 0)), + ), PreviewContent::SyntectHighlightedText(highlighted_lines) => { - build_syntect_highlighted_paragraph( + PreviewWidget::Paragraph(build_syntect_highlighted_paragraph( &highlighted_lines.lines, preview_block, target_line, preview_scroll, colorscheme.preview, inner.height, - ) + )) } - PreviewContent::Image(image) => image.paragraph(inner, preview_block), + PreviewContent::Image(image) => PreviewWidget::Image( + image.image_preview_widget(inner, preview_block), + ), // meta - PreviewContent::Loading => { + PreviewContent::Loading => PreviewWidget::Paragraph( build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY) .block(preview_block) .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)) - } - PreviewContent::NotSupported => build_meta_preview_paragraph( - inner, - PREVIEW_NOT_SUPPORTED_MSG, - FILL_CHAR_EMPTY, - ) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)), - PreviewContent::FileTooLarge => build_meta_preview_paragraph( - inner, - FILE_TOO_LARGE_MSG, - FILL_CHAR_EMPTY, - ) - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)), - PreviewContent::Timeout => { + .style(Style::default().add_modifier(Modifier::ITALIC)), + ), + PreviewContent::NotSupported => PreviewWidget::Paragraph( + build_meta_preview_paragraph( + inner, + PREVIEW_NOT_SUPPORTED_MSG, + FILL_CHAR_EMPTY, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + ), + PreviewContent::FileTooLarge => PreviewWidget::Paragraph( + build_meta_preview_paragraph( + inner, + FILE_TOO_LARGE_MSG, + FILL_CHAR_EMPTY, + ) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + ), + + PreviewContent::Timeout => PreviewWidget::Paragraph( build_meta_preview_paragraph(inner, TIMEOUT_MSG, FILL_CHAR_EMPTY) + .block(preview_block) + .alignment(Alignment::Left) + .style(Style::default().add_modifier(Modifier::ITALIC)), + ), + PreviewContent::Empty => { + PreviewWidget::Paragraph(Paragraph::new(Text::raw(EMPTY_STRING))) } - .block(preview_block) - .alignment(Alignment::Left) - .style(Style::default().add_modifier(Modifier::ITALIC)), - PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)), } } diff --git a/television/utils/image.rs b/television/utils/image.rs index bbd8b19..d0608c0 100644 --- a/television/utils/image.rs +++ b/television/utils/image.rs @@ -1,14 +1,14 @@ use image::imageops::FilterType; -use image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage}; -use ratatui::layout::{Alignment, Rect}; -use ratatui::prelude::{Color, Span, Style, Text}; -use ratatui::text::Line; -use ratatui::widgets::{Block, Paragraph}; +use image::{DynamicImage, GenericImageView, Pixel, Rgba}; +use ratatui::buffer::{Buffer, Cell}; +use ratatui::layout::{Position, Rect}; +use ratatui::prelude::Color; +use ratatui::widgets::{Block, Widget}; use std::fmt::Debug; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; -const PIXEL: char = '▀'; +static PIXEL_STRING: &str = "▀"; const FILTER_TYPE: FilterType = FilterType::Lanczos3; // use to reduce the size of the image before storing it @@ -18,14 +18,71 @@ const CACHED_HEIGHT: u32 = 128; const GRAY: Rgba = Rgba([242, 242, 242, 255]); const WHITE: Rgba = Rgba([255, 255, 255, 255]); -struct Cache { - area: Rect, - image: RgbaImage, +pub struct ImagePreviewWidget<'a> { + block: Block<'a>, + displayed_image: Arc>>, +} +impl<'a> ImagePreviewWidget<'a> { + pub fn new( + displayed_image: Arc>>, + block: ratatui::widgets::Block<'a>, + ) -> ImagePreviewWidget<'a> { + ImagePreviewWidget { + block, + displayed_image, + } + } +} +impl Widget for ImagePreviewWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + // I also put the render of the block to be similar has the preview paragraph, but I believe they are both useless since the block is already rendered + let inner = self.block.inner(area); + self.block.render(area, buf); + self.displayed_image + .lock() + .unwrap() + .as_ref() + .unwrap() + .render(inner, buf); + } +} + +#[derive(Default)] +pub struct DisplayedImage { + pub area: Rect, + cells: Vec>, +} + +impl Widget for &DisplayedImage { + fn render(self, area: Rect, buf: &mut Buffer) { + let height = self.cells.len(); + if height == 0 { + return; + } + let width = self.cells[0].len(); + // offset of the left top corner where the image is centered + let x_offset = (usize::from(area.width + 2 * area.x) - width) / 2; + let y_offset = (usize::from(area.height + 2 * area.y) - height) / 2; + + for (y, row) in self.cells.iter().enumerate() { + for (x, cell) in row.iter().enumerate() { + if let Some(buf_cell) = buf.cell_mut(Position::new( + u16::try_from(x_offset + x).unwrap_or(u16::MAX), + u16::try_from(y_offset + y).unwrap_or(u16::MAX), + )) { + *buf_cell = cell.clone(); + } + } + } + } } #[derive(Clone)] pub struct CachedImageData { image: DynamicImage, - inner_cache: Arc>>, + inner_cache: Arc>>, } impl Hash for CachedImageData { fn hash(&self, state: &mut H) { @@ -64,16 +121,16 @@ impl CachedImageData { self.image.width() } - fn cache(&self) -> &Arc>> { + fn cache(&self) -> &Arc>> { &self.inner_cache } - pub fn set_cache(&self, area: Rect, image: RgbaImage) { + pub fn set_cache(&self, area: Rect, cells: Vec>) { let mut mutex_cache = self.cache().lock().unwrap(); if let Some(cache) = mutex_cache.as_mut() { cache.area = area; - cache.image = image; + cache.cells = cells; } else { - *mutex_cache = Some(Cache { area, image }); + *mutex_cache = Some(DisplayedImage { area, cells }); }; } pub fn from_dynamic_image(dynamic_image: DynamicImage) -> Self { @@ -91,98 +148,108 @@ impl CachedImageData { }; CachedImageData::new(resized_image) } - fn text_from_rgba_image_ref(image_rgba: &RgbaImage) -> Text<'static> { - let lines = image_rgba + pub fn image_preview_widget<'a>( + &self, + inner: Rect, + preview_block: Block<'a>, + ) -> ImagePreviewWidget<'a> { + // inner has to be the inner of the preview_block given + + // if nothing in the cache of the image, or the area has changed, generate a new image to be displayed and cache it + if self.cache().lock().unwrap().is_none() + || self.cache().lock().unwrap().as_ref().unwrap().area != inner + { + self.set_cache(inner, self.cells_for_area(inner)); + } + ImagePreviewWidget::new(self.inner_cache.clone(), preview_block) + } + pub fn cells_for_area(&self, inner: Rect) -> Vec> { + // size of the available area + let preview_width = u32::from(inner.width); + let preview_height = u32::from(inner.height) * 2; // *2 because 2 pixels per character + + // resize if it doesn't fit in + let image_rgba = if self.image.width() > preview_width + || self.image.height() > preview_height + { + self.image + .resize(preview_width, preview_height, FILTER_TYPE) + .into_rgba8() + } else { + self.image.to_rgba8() + }; + //creation of the grid of cell + image_rgba // iter over pair of rows .rows() .step_by(2) .zip(image_rgba.rows().skip(1).step_by(2)) .enumerate() .map(|(double_row_y, (row_1, row_2))| { - Line::from_iter(row_1.into_iter().zip(row_2).enumerate().map( - |(x, (color_up, color_down))| { - convert_pixel_to_span( - color_up, - color_down, - (x, double_row_y), - ) - }, - )) + // create rows of cells + row_1 + .into_iter() + .zip(row_2) + .enumerate() + .map(|(x, (color_up, color_down))| { + let position = (x, double_row_y); + DoublePixel::new(*color_up, *color_down) + .add_grid_background(position) + .into_cell() + }) + .collect::>() }) - .collect::>(); - - Text::from(lines).centered() + .collect::>>() } - pub fn paragraph<'a>( - &self, - inner: Rect, - preview_block: Block<'a>, - ) -> Paragraph<'a> { - let preview_width = u32::from(inner.width); - let preview_height = u32::from(inner.height) * 2; // *2 because 2 pixels per character - let text_image = if self.cache().lock().unwrap().is_none() - || self.cache().lock().unwrap().as_ref().unwrap().area != inner - { - let image_rgba = if self.image.width() > preview_width - || self.image.height() > preview_height - { - //warn!("==========================="); - self.image - .resize(preview_width, preview_height, FILTER_TYPE) - .into_rgba8() - } else { - self.image.to_rgba8() - }; +} - // transform it into text - let text = Self::text_from_rgba_image_ref(&image_rgba); - // cached resized image - self.set_cache(inner, image_rgba); - text +// util to convert Rgba into ratatui's Cell +struct DoublePixel { + color_up: Rgba, + color_down: Rgba, +} +impl DoublePixel { + pub fn new(color_up: Rgba, color_down: Rgba) -> Self { + Self { + color_up, + color_down, + } + } + + pub fn add_grid_background(mut self, position: (usize, usize)) -> Self { + let color_up = self.color_up.0; + let color_down = self.color_down.0; + self.color_up = Self::blend_with_background(color_up, position, 0); + self.color_down = Self::blend_with_background(color_down, position, 1); + self + } + + fn blend_with_background( + color: impl Into>, + position: (usize, usize), + offset: usize, + ) -> Rgba { + let color = color.into(); + if color[3] == 255 { + color } else { - let cache = self.cache().lock().unwrap(); - let image = &cache.as_ref().unwrap().image; - Self::text_from_rgba_image_ref(image) - }; - Paragraph::new(text_image) - .block(preview_block) - .alignment(Alignment::Center) + let is_white = (position.0 + position.1 * 2 + offset) % 2 == 0; + let mut base = if is_white { WHITE } else { GRAY }; + base.blend(&color); + base + } + } + + pub fn into_cell(self) -> Cell { + let mut cell = Cell::new(PIXEL_STRING); + cell.set_bg(Self::convert_image_color_to_ratatui_color( + self.color_down, + )) + .set_fg(Self::convert_image_color_to_ratatui_color(self.color_up)); + cell + } + + fn convert_image_color_to_ratatui_color(color: Rgba) -> Color { + Color::Rgb(color[0], color[1], color[2]) } } - -pub fn convert_pixel_to_span<'a>( - color_up: &Rgba, - color_down: &Rgba, - position: (usize, usize), -) -> Span<'a> { - let color_up = color_up.0; - let color_down = color_down.0; - - let color_up = blend_with_background(color_up, position, 0); - let color_down = blend_with_background(color_down, position, 1); - - let color_up = convert_image_color_to_ratatui_color(color_up); - let color_down = convert_image_color_to_ratatui_color(color_down); - - let style = Style::default().fg(color_up).bg(color_down); - Span::styled(String::from(PIXEL), style) -} - -fn blend_with_background( - color: impl Into>, - position: (usize, usize), - offset: usize, -) -> Rgba { - let color = color.into(); - if color[3] == 255 { - color - } else { - let is_white = (position.0 + position.1 * 2 + offset) % 2 == 0; - let mut base = if is_white { WHITE } else { GRAY }; - base.blend(&color); - base - } -} -fn convert_image_color_to_ratatui_color(color: Rgba) -> Color { - Color::Rgb(color[0], color[1], color[2]) -}