Add the preview of images

- New image.rs file in utils to convert images in usable array
- new accepted type of files in utils/files.rs
- the enum PreviewContent has now an Image entry to handle images
- conversion from image file to a usable paragraph into preview
This commit is contained in:
azy 2025-02-01 18:36:31 +08:00
parent eaab4e966b
commit 724844ba34
8 changed files with 218 additions and 11 deletions

View File

@ -59,6 +59,7 @@ bat = { version = "0.24", default-features = false, features = ["regex-onig"] }
gag = "1.0" gag = "1.0"
nucleo = "0.5" nucleo = "0.5"
toml = "0.8" toml = "0.8"
image = "0.25.5"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winapi-util = "0.1.9" winapi-util = "0.1.9"

View File

@ -2,6 +2,7 @@ use std::sync::Arc;
use crate::channels::entry::{Entry, PreviewType}; use crate::channels::entry::{Entry, PreviewType};
use devicons::FileIcon; use devicons::FileIcon;
use ratatui::layout::Rect;
pub mod ansi; pub mod ansi;
pub mod cache; pub mod cache;
@ -10,6 +11,7 @@ pub mod previewers;
// previewer types // previewer types
use crate::utils::cache::RingSet; use crate::utils::cache::RingSet;
use crate::utils::syntax::HighlightedLines; use crate::utils::syntax::HighlightedLines;
use crate::utils::image::Image;
pub use previewers::basic::BasicPreviewer; pub use previewers::basic::BasicPreviewer;
pub use previewers::basic::BasicPreviewerConfig; pub use previewers::basic::BasicPreviewerConfig;
pub use previewers::command::CommandPreviewer; pub use previewers::command::CommandPreviewer;
@ -30,6 +32,7 @@ pub enum PreviewContent {
PlainText(Vec<String>), PlainText(Vec<String>),
PlainTextWrapped(String), PlainTextWrapped(String),
AnsiText(String), AnsiText(String),
Image(Image)
} }
impl PreviewContent { impl PreviewContent {
@ -44,6 +47,9 @@ impl PreviewContent {
PreviewContent::AnsiText(text) => { PreviewContent::AnsiText(text) => {
text.lines().count().try_into().unwrap_or(u16::MAX) text.lines().count().try_into().unwrap_or(u16::MAX)
} }
PreviewContent::Image(image) => {
image.pixel_grid.len().try_into().unwrap_or(u16::MAX)
}
_ => 0, _ => 0,
} }
} }
@ -164,11 +170,11 @@ impl Previewer {
} }
} }
fn dispatch_request(&mut self, entry: &Entry) -> Option<Arc<Preview>> { fn dispatch_request(&mut self, entry: &Entry, preview_window: Option<Rect>) -> Option<Arc<Preview>> {
match &entry.preview_type { match &entry.preview_type {
PreviewType::Basic => Some(self.basic.preview(entry)), PreviewType::Basic => Some(self.basic.preview(entry)),
PreviewType::EnvVar => Some(self.env_var.preview(entry)), PreviewType::EnvVar => Some(self.env_var.preview(entry)),
PreviewType::Files => self.file.preview(entry), PreviewType::Files => self.file.preview(entry, preview_window),
PreviewType::Command(cmd) => self.command.preview(entry, cmd), PreviewType::Command(cmd) => self.command.preview(entry, cmd),
PreviewType::None => Some(Arc::new(Preview::default())), PreviewType::None => Some(Arc::new(Preview::default())),
} }
@ -183,11 +189,11 @@ impl Previewer {
} }
} }
pub fn preview(&mut self, entry: &Entry) -> Option<Arc<Preview>> { pub fn preview(&mut self, entry: &Entry, preview_window: Option<Rect>) -> Option<Arc<Preview>> {
// if we haven't acknowledged the request yet, acknowledge it // if we haven't acknowledged the request yet, acknowledge it
self.requests.push(entry.clone()); self.requests.push(entry.clone());
if let Some(preview) = self.dispatch_request(entry) { if let Some(preview) = self.dispatch_request(entry, preview_window) {
return Some(preview); return Some(preview);
} }
// lookup request stack and return the most recent preview available // lookup request stack and return the most recent preview available

View File

@ -10,14 +10,16 @@ use std::sync::{
atomic::{AtomicU8, Ordering}, atomic::{AtomicU8, Ordering},
Arc, Arc,
}; };
use ratatui::layout::Rect;
use syntect::{highlighting::Theme, parsing::SyntaxSet}; use syntect::{highlighting::Theme, parsing::SyntaxSet};
use tracing::{debug, warn}; use tracing::{debug, warn};
use image::ImageReader;
use crate::channels::entry; use crate::channels::entry;
use crate::preview::cache::PreviewCache; use crate::preview::cache::PreviewCache;
use crate::preview::{previewers::meta, Preview, PreviewContent}; use crate::preview::{previewers::meta, Preview, PreviewContent};
use crate::utils::{ use crate::utils::{
image::Image,
files::FileType, files::FileType,
strings::preprocess_line, strings::preprocess_line,
syntax::{self, load_highlighting_assets, HighlightingAssetsExt}, syntax::{self, load_highlighting_assets, HighlightingAssetsExt},
@ -78,20 +80,20 @@ impl FilePreviewer {
self.cache.lock().get(&entry.name) self.cache.lock().get(&entry.name)
} }
pub fn preview(&mut self, entry: &entry::Entry) -> Option<Arc<Preview>> { pub fn preview(&mut self, entry: &entry::Entry, preview_window: Option<Rect>) -> Option<Arc<Preview>> {
if let Some(preview) = self.cached(entry) { if let Some(preview) = self.cached(entry) {
debug!("Preview cache hit for {:?}", entry.name); debug!("Preview cache hit for {:?}", entry.name);
if preview.partial_offset.is_some() { if preview.partial_offset.is_some() {
// preview is partial, spawn a task to compute the next chunk // preview is partial, spawn a task to compute the next chunk
// and return the partial preview // and return the partial preview
debug!("Spawning partial preview task for {:?}", entry.name); debug!("Spawning partial preview task for {:?}", entry.name);
self.handle_preview_request(entry, Some(preview.clone())); self.handle_preview_request(entry, Some(preview.clone()), preview_window);
} }
Some(preview) Some(preview)
} else { } else {
// preview is not in cache, spawn a task to compute the preview // preview is not in cache, spawn a task to compute the preview
debug!("Preview cache miss for {:?}", entry.name); debug!("Preview cache miss for {:?}", entry.name);
self.handle_preview_request(entry, None); self.handle_preview_request(entry, None, preview_window);
None None
} }
} }
@ -100,6 +102,7 @@ impl FilePreviewer {
&mut self, &mut self,
entry: &entry::Entry, entry: &entry::Entry,
partial_preview: Option<Arc<Preview>>, partial_preview: Option<Arc<Preview>>,
preview_window: Option<Rect>,
) { ) {
if self.in_flight_previews.lock().contains(&entry.name) { if self.in_flight_previews.lock().contains(&entry.name) {
debug!("Preview already in flight for {:?}", entry.name); debug!("Preview already in flight for {:?}", entry.name);
@ -126,6 +129,7 @@ impl FilePreviewer {
&syntax_theme, &syntax_theme,
&concurrent_tasks, &concurrent_tasks,
&in_flight_previews, &in_flight_previews,
preview_window,
); );
}); });
} }
@ -149,6 +153,7 @@ pub fn try_preview(
syntax_theme: &Arc<Theme>, syntax_theme: &Arc<Theme>,
concurrent_tasks: &Arc<AtomicU8>, concurrent_tasks: &Arc<AtomicU8>,
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>, in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
preview_window: Option<Rect>,
) { ) {
debug!("Computing preview for {:?}", entry.name); debug!("Computing preview for {:?}", entry.name);
let path = PathBuf::from(&entry.name); let path = PathBuf::from(&entry.name);
@ -236,6 +241,43 @@ pub fn try_preview(
cache.lock().insert(entry.name.clone(), &p); cache.lock().insert(entry.name.clone(), &p);
} }
} }
} else if matches!(FileType::from(&path), FileType::Image)
{
debug!("File is an image: {:?}", entry.name);
let (window_height, window_width) = if let Some(preview_window) = preview_window{
// it should be a better way to know the size of the border to remove than this magic number
let padding = 5;
((preview_window.height - padding /2 ) * 2, preview_window.width - padding)
}else{
warn!("Error opening image, impossible to display without information about the size of the preview window");
let p = meta::not_supported(&entry.name);
cache.lock().insert(entry.name.clone(), &p);
return;
};
match ImageReader::open(path).unwrap().decode() {
Ok(image) => {
debug!("Width: {:}", window_width);
let image = Image::from_dynamic_image(image, window_height as u32, window_width as u32);
let total_lines = image.pixel_grid.len().try_into().unwrap_or(u16::MAX);
let content = PreviewContent::Image(image);
let preview = Arc::new(Preview::new(
entry.name.clone(),
content,
entry.icon,
None,
total_lines,
));
cache.lock().insert(entry.name.clone(), &preview);
}
Err(e) => {
warn!("Error opening file: {:?}", e);
let p = meta::not_supported(&entry.name);
cache.lock().insert(entry.name.clone(), &p);
}
}
} else { } else {
debug!("File isn't text-based: {:?}", entry.name); debug!("File isn't text-based: {:?}", entry.name);
let preview = meta::not_supported(&entry.name); let preview = meta::not_supported(&entry.name);

View File

@ -21,6 +21,7 @@ use ratatui::{
}; };
use std::str::FromStr; use std::str::FromStr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::utils::image::{Image, ImageColor, PIXEL};
#[allow(dead_code)] #[allow(dead_code)]
const FILL_CHAR_SLANTED: char = ''; const FILL_CHAR_SLANTED: char = '';
@ -68,6 +69,10 @@ pub fn build_preview_paragraph<'a>(
colorscheme.preview, colorscheme.preview,
) )
} }
PreviewContent::Image(image) => {
build_image_paragraph(image, preview_block,colorscheme.preview)
}
// meta // meta
PreviewContent::Loading => { PreviewContent::Loading => {
build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY) build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY)
@ -216,6 +221,23 @@ fn build_syntect_highlighted_paragraph(
.scroll((preview_scroll, 0)) .scroll((preview_scroll, 0))
} }
fn build_image_paragraph(
image: Image,
preview_block: Block<'_>,
colorscheme: PreviewColorscheme,
) -> Paragraph<'_> {
let lines = image.pixel_grid.iter().map(|double_pixel_line|
Line::from_iter(
double_pixel_line.iter().map(|(color_up, color_down)|
convert_pixel_to_span(*color_up, *color_down, Some(colorscheme.highlight_bg)))
)
).collect::<Vec<Line>>();
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.wrap(Wrap { trim: true })
}
pub fn build_meta_preview_paragraph<'a>( pub fn build_meta_preview_paragraph<'a>(
inner: Rect, inner: Rect,
message: &str, message: &str,
@ -509,3 +531,42 @@ fn compute_cache_key(entry: &Entry) -> String {
} }
cache_key cache_key
} }
pub fn convert_pixel_to_span<'a>(
color_up: ImageColor,
color_down: ImageColor,
background: Option<Color>,
) -> Span<'a> {
let bg_color = match background {
Some(Color::Rgb(r, g, b)) => Some((r,g,b)),
_ => {None}
};
let (color_up, color_down) = if let Some(bg_color) = bg_color {
let color_up_with_alpha = Color::Rgb(
(color_up.r * color_up.a + bg_color.0 * 255 - color_up.a) / 255,
(color_up.b * color_up.a + bg_color.1 * 255 - color_up.a) / 255,
(color_up.b * color_up.a + bg_color.2 * 255 - color_up.a) / 255,
);
let color_down_with_alpha = Color::Rgb(
(color_down.r * color_down.a + bg_color.0 * 255 - color_down.a) / 255,
(color_down.b * color_down.a + bg_color.1 * 255 - color_down.a) / 255,
(color_down.b * color_down.a + bg_color.2 * 255 - color_down.a) / 255,
);
(color_up_with_alpha,color_down_with_alpha)
}else{
(convert_image_color_to_ratatui_color(color_up),
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 convert_image_color_to_ratatui_color(
color: ImageColor,
) -> Color {
Color::Rgb(color.r, color.g, color.b)
}

View File

@ -587,10 +587,11 @@ impl Television {
if self.config.ui.show_preview_panel if self.config.ui.show_preview_panel
&& !matches!(selected_entry.preview_type, PreviewType::None) && !matches!(selected_entry.preview_type, PreviewType::None)
{ {
// preview content
let maybe_preview = self.previewer.preview(&selected_entry);
let _ = self.previewer.preview(&selected_entry); // preview content
let maybe_preview = self.previewer.preview(&selected_entry, layout.preview_window);
let _ = self.previewer.preview(&selected_entry, layout.preview_window);
if let Some(preview) = &maybe_preview { if let Some(preview) = &maybe_preview {
self.current_preview_total_lines = preview.total_lines; self.current_preview_total_lines = preview.total_lines;

View File

@ -104,6 +104,7 @@ pub fn get_file_size(path: &Path) -> Option<u64> {
#[derive(Debug)] #[derive(Debug)]
pub enum FileType { pub enum FileType {
Text, Text,
Image,
Other, Other,
Unknown, Unknown,
} }
@ -118,6 +119,9 @@ where
if is_known_text_extension(p) { if is_known_text_extension(p) {
return FileType::Text; return FileType::Text;
} }
if is_accepted_image_extension(p){
return FileType::Image;
}
if let Ok(mut f) = File::open(p) { if let Ok(mut f) = File::open(p) {
let mut buffer = [0u8; 256]; let mut buffer = [0u8; 256];
if let Ok(bytes_read) = f.read(&mut buffer) { if let Ok(bytes_read) = f.read(&mut buffer) {
@ -480,3 +484,38 @@ lazy_static! {
.copied() .copied()
.collect(); .collect();
} }
pub fn is_accepted_image_extension<P>(path: P) -> bool
where
P: AsRef<Path>,
{
path.as_ref()
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| KNOWN_IMAGE_FILE_EXTENSIONS.contains(ext))
}
lazy_static! {
static ref KNOWN_IMAGE_FILE_EXTENSIONS: FxHashSet<&'static str> = [
//"avif",
//"bmp",
//"dds",
//"farbfeld",
"gif",
//"hdr",
//"ico",
"jpeg",
"jpg",
//"exr",
"png",
//"pnm",
//"qoi",
//"tga",
//"tiff",
"webp",
]
.iter()
.copied()
.collect();
}

56
television/utils/image.rs Normal file
View File

@ -0,0 +1,56 @@
use image::DynamicImage;
use image::imageops::FilterType;
pub const PIXEL: char = '▀' ;
const FILTER: FilterType = FilterType::Triangle;
#[derive(Clone, Debug)]
pub struct Image {
pub pixel_grid: Vec<Vec<(ImageColor, ImageColor)>>
}
impl Image {
pub fn new(pixel_grid: Vec<Vec<(ImageColor, ImageColor)>>) -> Self {
Image { pixel_grid}
}
pub fn from_dynamic_image(dynamic_image: DynamicImage, height: u32, width: u32) -> Self {
let image = if dynamic_image.height() > height || dynamic_image.width() > width {
println!("{}", dynamic_image.height());
dynamic_image.resize(width, height, FILTER)
}else{
dynamic_image
};
let image = image.into_rgba8();
let pixel_grid = image.rows()
.step_by(2)
.zip(image.rows().skip(1).step_by(2))
.map(|(row_1, row_2)| {
row_1.zip(row_2)
.map(|(pixel_1, pixel_2)|
(
ImageColor {
r: pixel_1.0[0],
g: pixel_1.0[1],
b: pixel_1.0[2],
a: pixel_1.0[3],
},
ImageColor {
r: pixel_2.0[0],
g: pixel_2.0[1],
b: pixel_2.0[2],
a: pixel_1.0[3],
}))
.collect::<Vec<(ImageColor,ImageColor)>>()
})
.collect::<Vec<Vec<(ImageColor,ImageColor)>>>();
Image::new(pixel_grid)
}
}
#[derive(Clone, Copy, Debug)]
pub struct ImageColor{
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8
}

View File

@ -9,3 +9,4 @@ pub mod stdin;
pub mod strings; pub mod strings;
pub mod syntax; pub mod syntax;
pub mod threads; pub mod threads;
pub mod image;