mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-07 12:05:34 +00:00
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:
parent
eaab4e966b
commit
724844ba34
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
|
@ -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)
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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
56
television/utils/image.rs
Normal 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
|
||||||
|
}
|
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user