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
This commit is contained in:
azy 2025-02-23 22:40:40 +09:00
parent 51eef8ba8d
commit b35304f1c3
2 changed files with 239 additions and 140 deletions

View File

@ -4,13 +4,17 @@ use crate::preview::{
PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG, PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG,
}; };
use crate::screen::colors::{Colorscheme, PreviewColorscheme}; use crate::screen::colors::{Colorscheme, PreviewColorscheme};
use crate::utils::image::ImagePreviewWidget;
use crate::utils::strings::{ use crate::utils::strings::{
replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig, replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
EMPTY_STRING, EMPTY_STRING,
}; };
use anyhow::Result; use anyhow::Result;
use devicons::FileIcon; 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::Frame;
use ratatui::{ use ratatui::{
layout::{Alignment, Rect}, layout::{Alignment, Rect},
@ -22,6 +26,22 @@ use std::str::FromStr;
const FILL_CHAR_SLANTED: char = ''; const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: 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)] #[allow(clippy::too_many_arguments)]
pub fn draw_preview_content_block( pub fn draw_preview_content_block(
f: &mut Frame, f: &mut Frame,
@ -40,7 +60,7 @@ pub fn draw_preview_content_block(
)?; )?;
// render the preview content // render the preview content
let rp = build_preview_paragraph( let rp = build_preview_widget(
inner, inner,
&preview_state.preview.content, &preview_state.preview.content,
preview_state.target_line, preview_state.target_line,
@ -51,13 +71,13 @@ pub fn draw_preview_content_block(
Ok(()) Ok(())
} }
pub fn build_preview_paragraph<'a>( pub fn build_preview_widget<'a>(
inner: Rect, inner: Rect,
preview_content: &'a PreviewContent, preview_content: &'a PreviewContent,
target_line: Option<u16>, target_line: Option<u16>,
preview_scroll: u16, preview_scroll: u16,
colorscheme: &'a Colorscheme, colorscheme: &'a Colorscheme,
) -> Paragraph<'a> { ) -> PreviewWidget<'a> {
let preview_block = let preview_block =
Block::default().style(Style::default()).padding(Padding { Block::default().style(Style::default()).padding(Padding {
top: 0, top: 0,
@ -65,67 +85,79 @@ pub fn build_preview_paragraph<'a>(
bottom: 0, bottom: 0,
left: 1, left: 1,
}); });
match preview_content { match preview_content {
PreviewContent::AnsiText(text) => { PreviewContent::AnsiText(text) => PreviewWidget::Paragraph(
build_ansi_text_paragraph(text, preview_block, preview_scroll) 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::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( build_plain_text_wrapped_paragraph(
content, content,
preview_block, preview_block,
colorscheme.preview, colorscheme.preview,
) )
.scroll((preview_scroll, 0)) .scroll((preview_scroll, 0)),
} ),
PreviewContent::SyntectHighlightedText(highlighted_lines) => { PreviewContent::SyntectHighlightedText(highlighted_lines) => {
build_syntect_highlighted_paragraph( PreviewWidget::Paragraph(build_syntect_highlighted_paragraph(
&highlighted_lines.lines, &highlighted_lines.lines,
preview_block, preview_block,
target_line, target_line,
preview_scroll, preview_scroll,
colorscheme.preview, colorscheme.preview,
inner.height, inner.height,
) ))
} }
PreviewContent::Image(image) => image.paragraph(inner, preview_block), PreviewContent::Image(image) => PreviewWidget::Image(
image.image_preview_widget(inner, preview_block),
),
// meta // meta
PreviewContent::Loading => { PreviewContent::Loading => PreviewWidget::Paragraph(
build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY) build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY)
.block(preview_block) .block(preview_block)
.alignment(Alignment::Left) .alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)) .style(Style::default().add_modifier(Modifier::ITALIC)),
} ),
PreviewContent::NotSupported => build_meta_preview_paragraph( PreviewContent::NotSupported => PreviewWidget::Paragraph(
inner, build_meta_preview_paragraph(
PREVIEW_NOT_SUPPORTED_MSG, inner,
FILL_CHAR_EMPTY, PREVIEW_NOT_SUPPORTED_MSG,
) FILL_CHAR_EMPTY,
.block(preview_block) )
.alignment(Alignment::Left) .block(preview_block)
.style(Style::default().add_modifier(Modifier::ITALIC)), .alignment(Alignment::Left)
PreviewContent::FileTooLarge => build_meta_preview_paragraph( .style(Style::default().add_modifier(Modifier::ITALIC)),
inner, ),
FILE_TOO_LARGE_MSG, PreviewContent::FileTooLarge => PreviewWidget::Paragraph(
FILL_CHAR_EMPTY, build_meta_preview_paragraph(
) inner,
.block(preview_block) FILE_TOO_LARGE_MSG,
.alignment(Alignment::Left) FILL_CHAR_EMPTY,
.style(Style::default().add_modifier(Modifier::ITALIC)), )
PreviewContent::Timeout => { .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) 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)),
} }
} }

View File

@ -1,14 +1,14 @@
use image::imageops::FilterType; use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView, Pixel, Rgba, RgbaImage}; use image::{DynamicImage, GenericImageView, Pixel, Rgba};
use ratatui::layout::{Alignment, Rect}; use ratatui::buffer::{Buffer, Cell};
use ratatui::prelude::{Color, Span, Style, Text}; use ratatui::layout::{Position, Rect};
use ratatui::text::Line; use ratatui::prelude::Color;
use ratatui::widgets::{Block, Paragraph}; use ratatui::widgets::{Block, Widget};
use std::fmt::Debug; use std::fmt::Debug;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
const PIXEL: char = '▀'; static PIXEL_STRING: &str = "";
const FILTER_TYPE: FilterType = FilterType::Lanczos3; const FILTER_TYPE: FilterType = FilterType::Lanczos3;
// use to reduce the size of the image before storing it // use to reduce the size of the image before storing it
@ -18,14 +18,71 @@ const CACHED_HEIGHT: u32 = 128;
const GRAY: Rgba<u8> = Rgba([242, 242, 242, 255]); const GRAY: Rgba<u8> = Rgba([242, 242, 242, 255]);
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]); const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
struct Cache { pub struct ImagePreviewWidget<'a> {
area: Rect, block: Block<'a>,
image: RgbaImage, displayed_image: Arc<Mutex<Option<DisplayedImage>>>,
}
impl<'a> ImagePreviewWidget<'a> {
pub fn new(
displayed_image: Arc<Mutex<Option<DisplayedImage>>>,
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<Vec<Cell>>,
}
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)] #[derive(Clone)]
pub struct CachedImageData { pub struct CachedImageData {
image: DynamicImage, image: DynamicImage,
inner_cache: Arc<Mutex<Option<Cache>>>, inner_cache: Arc<Mutex<Option<DisplayedImage>>>,
} }
impl Hash for CachedImageData { impl Hash for CachedImageData {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
@ -64,16 +121,16 @@ impl CachedImageData {
self.image.width() self.image.width()
} }
fn cache(&self) -> &Arc<Mutex<Option<Cache>>> { fn cache(&self) -> &Arc<Mutex<Option<DisplayedImage>>> {
&self.inner_cache &self.inner_cache
} }
pub fn set_cache(&self, area: Rect, image: RgbaImage) { pub fn set_cache(&self, area: Rect, cells: Vec<Vec<Cell>>) {
let mut mutex_cache = self.cache().lock().unwrap(); let mut mutex_cache = self.cache().lock().unwrap();
if let Some(cache) = mutex_cache.as_mut() { if let Some(cache) = mutex_cache.as_mut() {
cache.area = area; cache.area = area;
cache.image = image; cache.cells = cells;
} else { } else {
*mutex_cache = Some(Cache { area, image }); *mutex_cache = Some(DisplayedImage { area, cells });
}; };
} }
pub fn from_dynamic_image(dynamic_image: DynamicImage) -> Self { pub fn from_dynamic_image(dynamic_image: DynamicImage) -> Self {
@ -91,98 +148,108 @@ impl CachedImageData {
}; };
CachedImageData::new(resized_image) CachedImageData::new(resized_image)
} }
fn text_from_rgba_image_ref(image_rgba: &RgbaImage) -> Text<'static> { pub fn image_preview_widget<'a>(
let lines = image_rgba &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<Vec<Cell>> {
// 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 // iter over pair of rows
.rows() .rows()
.step_by(2) .step_by(2)
.zip(image_rgba.rows().skip(1).step_by(2)) .zip(image_rgba.rows().skip(1).step_by(2))
.enumerate() .enumerate()
.map(|(double_row_y, (row_1, row_2))| { .map(|(double_row_y, (row_1, row_2))| {
Line::from_iter(row_1.into_iter().zip(row_2).enumerate().map( // create rows of cells
|(x, (color_up, color_down))| { row_1
convert_pixel_to_span( .into_iter()
color_up, .zip(row_2)
color_down, .enumerate()
(x, double_row_y), .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::<Vec<Cell>>()
}) })
.collect::<Vec<Line>>(); .collect::<Vec<Vec<Cell>>>()
Text::from(lines).centered()
} }
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 // util to convert Rgba into ratatui's Cell
let text = Self::text_from_rgba_image_ref(&image_rgba); struct DoublePixel {
// cached resized image color_up: Rgba<u8>,
self.set_cache(inner, image_rgba); color_down: Rgba<u8>,
text }
impl DoublePixel {
pub fn new(color_up: Rgba<u8>, color_down: Rgba<u8>) -> 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<Rgba<u8>>,
position: (usize, usize),
offset: usize,
) -> Rgba<u8> {
let color = color.into();
if color[3] == 255 {
color
} else { } else {
let cache = self.cache().lock().unwrap(); let is_white = (position.0 + position.1 * 2 + offset) % 2 == 0;
let image = &cache.as_ref().unwrap().image; let mut base = if is_white { WHITE } else { GRAY };
Self::text_from_rgba_image_ref(image) base.blend(&color);
}; base
Paragraph::new(text_image) }
.block(preview_block) }
.alignment(Alignment::Center)
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<u8>) -> Color {
Color::Rgb(color[0], color[1], color[2])
} }
} }
pub fn convert_pixel_to_span<'a>(
color_up: &Rgba<u8>,
color_down: &Rgba<u8>,
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<Rgba<u8>>,
position: (usize, usize),
offset: usize,
) -> Rgba<u8> {
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<u8>) -> Color {
Color::Rgb(color[0], color[1], color[2])
}