diff --git a/Cargo.toml b/Cargo.toml index 61e2c45..68bf2c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ rfd = "0.15.0" imagesize = "0.13.0" eframe = { version = "0.29.1", features = ["default"] } egui_extras = { version = "0.29.1", features = ["all_loaders"]} -image = {version = "0.25.5", features = ["jpeg", "png"]} +image = {version = "0.25.5"} egui_animation = "0.6.0" simple-easing = "1.0.1" log = "0.4.22" @@ -32,6 +32,7 @@ serde = {version = "1.0.215", features = ["derive"]} serde_derive = "1.0.215" dirs = "5.0.1" toml = "0.8.19" +rayon = "1.10.0" [profile.dev.package."*"] opt-level = 3 diff --git a/src/app.rs b/src/app.rs index 3f84a56..1d0877b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,15 +4,14 @@ use cirrus_theming::v1::Theme; use eframe::egui::{self, Align, Color32, Context, CursorIcon, Frame, ImageSource, Layout, Margin, Rect, Shadow, Stroke, Style, TextStyle, Vec2}; use egui_notify::ToastLevel; - -use crate::{config::config::Config, files, image::Image, image_loader::ImageLoader, info_box::InfoBox, magnification_panel::MagnificationPanel, toasts::ToastsManager, window_scaling::WindowScaling, zoom_pan::ZoomPan}; +use crate::{config::config::Config, files, image::image::Image, image_loader::ImageLoader, info_box::InfoBox, magnification_panel::MagnificationPanel, notifier::NotifierAPI, window_scaling::WindowScaling, zoom_pan::ZoomPan}; pub struct Roseate { theme: Theme, image: Option, zoom_pan: ZoomPan, info_box: InfoBox, - toasts: ToastsManager, + notifier: NotifierAPI, magnification_panel: MagnificationPanel, window_scaling: WindowScaling, last_window_rect: Rect, @@ -21,21 +20,25 @@ pub struct Roseate { } impl Roseate { - pub fn new(image: Option, theme: Theme, mut toasts: ToastsManager, config: Config) -> Self { + pub fn new(image: Option, theme: Theme, mut notifier: NotifierAPI, config: Config) -> Self { let mut image_loader = ImageLoader::new(); if image.is_some() { - image_loader.load_image(&mut image.clone().unwrap(), config.image.loading.initial.lazy_loading); + image_loader.load_image( + &mut image.clone().unwrap(), + config.image.loading.initial.lazy_loading, + &mut notifier + ); } - let zoom_pan = ZoomPan::new(&config, &mut toasts); - let info_box = InfoBox::new(&config, &mut toasts); - let magnification_panel = MagnificationPanel::new(&config, &mut toasts); + let zoom_pan = ZoomPan::new(&config, &mut notifier); + let info_box = InfoBox::new(&config, &mut notifier); + let magnification_panel = MagnificationPanel::new(&config, &mut notifier); Self { image, theme, - toasts, + notifier, zoom_pan, info_box, magnification_panel, @@ -118,7 +121,7 @@ impl eframe::App for Roseate { } } - self.toasts.update(ctx); + self.notifier.update(ctx); if self.image.is_none() { // Collect dropped files. @@ -134,7 +137,7 @@ impl eframe::App for Roseate { let mut image = Image::from_path(path); self.image = Some(image.clone()); - self.image_loader.load_image(&mut image, true); + self.image_loader.load_image(&mut image, true, &mut self.notifier); } }); @@ -176,10 +179,10 @@ impl eframe::App for Roseate { Ok(mut image) => { self.image = Some(image.clone()); - self.image_loader.load_image(&mut image, self.config.image.loading.gui.lazy_loading); + self.image_loader.load_image(&mut image, self.config.image.loading.gui.lazy_loading, &mut self.notifier); }, Err(error) => { - self.toasts.toast_and_log(error.into(), ToastLevel::Error) + self.notifier.toasts.lock().unwrap().toast_and_log(error.into(), ToastLevel::Error) .duration(Some(Duration::from_secs(5))); }, } @@ -210,7 +213,7 @@ impl eframe::App for Roseate { self.info_box.update(ctx); self.zoom_pan.update(ctx); - self.image_loader.update(&mut self.toasts); + self.image_loader.update(); self.magnification_panel.update(ctx, &mut self.zoom_pan); let image = self.image.clone().unwrap(); @@ -275,18 +278,20 @@ impl eframe::App for Roseate { Frame::none() .outer_margin(Margin {left: 10.0, bottom: 7.0, ..Default::default()}) ).show(ctx, |ui| { - if let Some(loading) = &self.image_loader.image_loading { - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - ui.add( - egui::Spinner::new() - .color(Color32::from_hex("#e05f78").unwrap()) // NOTE: This should be the default accent colour. - .size(20.0) - ); - - if let Some(message) = &loading.message { - ui.label(message); - } - }); + if let Ok(loading_status) = self.notifier.loading_status.try_read() { + if let Some(loading) = loading_status.as_ref() { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add( + egui::Spinner::new() + .color(Color32::from_hex("#e05f78").unwrap()) // NOTE: This should be the default accent colour. + .size(20.0) + ); + + if let Some(message) = &loading.message { + ui.label(message); + } + }); + } } } ); diff --git a/src/error.rs b/src/error.rs index a12a3d4..f8b7ce8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,7 +2,7 @@ use std::{fmt::{self, Display, Formatter}, path::PathBuf}; #[derive(Debug, Clone)] pub enum Error { - FileNotFound(PathBuf), + FileNotFound(PathBuf, Option), NoFileSelected, FailedToApplyOptimizations(String), ImageFormatNotSupported(String), @@ -19,9 +19,15 @@ impl Error { impl Display for Error { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { - Error::FileNotFound(path) => write!( - f, "The file path given '{}' does not exist!", path.to_string_lossy() - ), + Error::FileNotFound(path, error) => { + let mut msg = format!("The file path given '{}' does not exist!", path.to_string_lossy()); + + if let Some(error_msg) = error { + msg += &format!("Error: {}", error_msg); + } + + write!(f, "{}", msg) + }, Error::NoFileSelected => write!( f, "No file was selected in the file dialogue!" ), diff --git a/src/files.rs b/src/files.rs index 5f63158..e65b24d 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,7 +1,7 @@ use rfd::FileDialog; use crate::error::Error; -use crate::image::Image; +use crate::image::image::Image; pub fn select_image() -> Result { let image_path = FileDialog::new() @@ -11,7 +11,7 @@ pub fn select_image() -> Result { let image_or_error = match image_path { Some(path) => { if !path.exists() { - Err(Error::FileNotFound(path)) + Err(Error::FileNotFound(path, None)) } else { Ok(Image::from_path(&path)) } diff --git a/src/image.rs b/src/image.rs deleted file mode 100644 index 79b34ec..0000000 --- a/src/image.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::{fs::{self, File}, io::{BufReader, Cursor}, path::{Path, PathBuf}, sync::{Arc, Mutex}}; - -use log::debug; -use eframe::egui::Vec2; -use imagesize::ImageSize; -use svg_metadata::Metadata; -use display_info::DisplayInfo; -use image::{ImageFormat, ImageReader}; - -use crate::error::Error; - -#[derive(Clone)] -pub struct Image { - pub image_size: ImageSize, - pub image_path: Arc, - pub image_bytes: Arc>>>, - // Look! I know you see that type above me but just - // so you know, I'm NOT crazy... well not yet at least... - // - // Anyways, as you can see, `image_bytes` is an `Arc>>>` - // this is because we need to be able to manipulate this under a thread so we can load - // images in a background thread (see https://github.com/cloudy-org/roseate/issues/24). - // - // The first Arc allows us to share the SAME image_bytes safely across threads even when we - // image.clone() that bitch, while Mutex ensures that only one thread accesses or modifies the image - // bytes and also SO THE RUST COMPILER CAN SHUT THE FUCK UP.. YES I KNOW THAT IT'S UNSAFE BECAUSE ANOTHER - // THREAD CAN FUCK IT UP BUT YOU DO REALISE MY PROGRAM IS SMART ENOUGH TO NOT DO THAT... uhmmm uhmmm... anyways... - // I use an Option because an image that is not yet loaded will have no bytes in memory and the second Arc is there - // so we can image.clone() and not be doubling the image bytes in memory and turn into the next Google Chrome web browser. 💀 - // - // Kind regards, - // Goldy -} - -#[derive(Debug)] -pub enum ImageOptimization { - /// Downsamples the image to this width and height. - /// - /// Images don't always have to be displayed at it's native resolution, - /// especially when the image is significantly bigger than our monitor - /// can even display so to save memory we downsample the image. Downsampling - /// decreases the memory usage of the image at the cost of time wasted actually - /// resizing the image. The bigger the image the more time it will take to downsample - /// but the memory savings are very valuable. - /// - /// NOTE: "The image's aspect ratio is preserved. The image is scaled to the maximum - /// possible size that fits within the bounds specified by the width and height." ~ Image Crate - Downsample(u32, u32), -} - -impl Image { - pub fn from_path(path: &Path) -> Self { - // Changed this to unwrap_or_default so it returns an empty - // string ("") and doesn't panic if a file has no extension. I need to begin adding tests. - let extension = path.extension().unwrap_or_default(); - - let image_size: ImageSize = if extension == "svg" { - get_svg_image_size(&path) - } else { - // I use 'imagesize' crate to get the image size - // because it's A LOT faster as it only partially loads the image bytes. - imagesize::size(path).expect( - "Failed to retrieve the dimensions of the image!" - ) - }; - - Self { - image_size, - image_path: Arc::new(path.to_owned()), - image_bytes: Arc::new(Mutex::new(None)) - } - } - - pub fn load_image(&mut self, optimizations: &[ImageOptimization]) -> Result<(), Error> { - if optimizations.is_empty() { - debug!("No optimizations were set so loading with fs::read instead..."); - - let mut image_bytes_lock = self.image_bytes.lock().unwrap(); - - *image_bytes_lock = Some( - Arc::from(fs::read(self.image_path.as_ref()).expect("Failed to read image with fs::read!")) - ); - - return Ok(()); // I avoid image crate here as loading the bytes with fs::read is - // A LOT faster and no optimizations need to be done so we don't need image crate. - } - - debug!("Opening file into buf reader..."); - - let image_file = File::open(self.image_path.as_ref()).expect( - &format!("Failed to open file for the image '{}'", self.image_path.to_string_lossy()) - ); - let image_buf_reader = BufReader::new(image_file); // apparently this is faster for larger files as - // it avoids loading files line by line hence less system calls to the disk. (EDIT: I'm defiantly noticing a speed difference) - - debug!("Loading image into image crate DynamicImage so optimizations can be applied..."); - - let image_result = ImageReader::new(image_buf_reader) - .with_guessed_format() - .unwrap() - .decode(); - - if let Err(image_error) = image_result { - // TODO: warn the user that optimizations failed to apply via a toast and log. - // NOTE: Although we need ToastManager from the feat/toml-config branch first. - let result_of_second_load = self.load_image(&[]); // load image without optimizations - - if result_of_second_load.is_err() { - return result_of_second_load; - } - - return Err( - Error::FailedToApplyOptimizations( - format!( - "Failed to decode and load image with \ - image crate to apply optimizations! \nError: {}.", - image_error - ) - ) - ) - } - - let mut image = image_result.unwrap(); - - for optimization in optimizations { - debug!("Applying '{:?}' optimization to image...", optimization); - - match optimization { - ImageOptimization::Downsample(width, height) => { - image = image.resize( - *width, - *height, - image::imageops::FilterType::Lanczos3 - ); - }, - } - } - - // TODO: I think writing the modified image into this buffer will make the memory usage - // spike quite a lot as it will basically be duplicating it as we already the unmodified image - // in self.image_bytes. Maybe we should clear self.image_bytes before we write the modified image to the buffer. - let mut buffer: Vec = Vec::new(); - - image.write_to(&mut Cursor::new(&mut buffer), ImageFormat::WebP).expect( - "Failed to write optimized image to buffer!" - ); - - let mut image_bytes_lock = self.image_bytes.lock().unwrap(); - *image_bytes_lock = Some(Arc::from(buffer)); - - Ok(()) - } -} - -// NOTE: should this be here? Don't know. -pub fn apply_image_optimizations(mut optimizations: Vec, image_size: &ImageSize) -> Vec { - let all_display_infos = DisplayInfo::all().expect( - "Failed to get information about your display monitor!" - ); - - // NOTE: I don't think the first monitor is always the primary and - // if that is the case then we're gonna have a problem. (i.e images overly downsampled or not at all) - let primary_display_maybe = all_display_infos.first().expect( - "Uhhhhh, you don't have a monitor. WHAT!" - ); - - let marginal_allowance: f32 = 1.3; - - let (width, height) = ( - primary_display_maybe.width as f32 * marginal_allowance, - primary_display_maybe.height as f32 * marginal_allowance - ); - - debug!( - "Display Size: {} x {}", - primary_display_maybe.width, - primary_display_maybe.height - ); - debug!( - "Display Size + Downsample Marginal Allowance: {} x {}", width, height - ); - debug!( - "Image Size: {} x {}", - image_size.width, - image_size.height - ); - - // If the image is a lot bigger than the user's monitor - // then apply the downsample optimization for this image. - if image_size.width > width as usize && image_size.height > height as usize { - optimizations.push(ImageOptimization::Downsample(width as u32, height as u32)); - } - - optimizations -} - -fn get_primary_display_info() -> DisplayInfo { - let all_display_infos = DisplayInfo::all().expect( - "Failed to get information about your display monitor!" - ); - - // NOTE: I don't think the first monitor is always the primary and - // if that is the case then we're gonna have a problem. (i.e images overly downsampled or not at all) - let primary_display_maybe = all_display_infos.first().expect( - "Uhhhhh, you don't have a monitor. WHAT!" - ); - - primary_display_maybe.clone() -} - -fn get_svg_image_size(path: &Path) -> ImageSize { - let metadata = Metadata::parse_file(path).expect( - "Failed to parse metadata of the svg file!" - ); - - let width = metadata.width().expect("Failed to get SVG width"); - let height = metadata.height().expect("Failed to get SVG height"); - - let display_info = get_primary_display_info(); - - let image_to_display_ratio = Vec2::new(width as f32, height as f32) / - Vec2::new(display_info.width as f32, display_info.height as f32); - - // Temporary solution to give svg images a little bit higher quality. - ImageSize { - width: (width * (1.0 + (1.0 - image_to_display_ratio.x)) as f64) as usize, - height: (height * (1.0 + (1.0 - image_to_display_ratio.y)) as f64) as usize - } -} \ No newline at end of file diff --git a/src/image/fast_downsample.rs b/src/image/fast_downsample.rs new file mode 100644 index 0000000..ee12456 --- /dev/null +++ b/src/image/fast_downsample.rs @@ -0,0 +1,68 @@ +use rayon::prelude::*; +use imagesize::ImageSize; +use std::sync::{Arc, Mutex}; + +pub fn fast_downsample(pixels: Vec, image_size: &ImageSize, target_size: (u32, u32)) -> (Vec, (u32, u32)) { + let (target_width, target_height) = target_size; + + let scale_factor = (image_size.width as f32 / target_width as f32) + .max(image_size.height as f32 / target_height as f32); + + let new_width = (image_size.width as f32 / scale_factor) as u32; + let new_height = (image_size.height as f32 / scale_factor) as u32; + + let downsampled_pixels = Arc::new( + Mutex::new( + vec![0u8; (new_width * new_height * 3) as usize] + ) + ); + + // '(0..new_height).into_par_iter()' allocates each vertical line to a CPU thread. + (0..new_height).into_par_iter().for_each(|y| { + let original_vertical_pos = y as f32 * scale_factor; + + for x in 0..new_width { + let original_horizontal_pos = x as f32 * scale_factor; + let mut rgb_sum = [0u16; 3]; // basically --> "R, G, B" + + // Here we basically take a 2x2 square block (4 pixels) from the source image so we can + // average their colour values to downscale that to one pixel in the downsampled image. + for vertical_offset in 0..2 { + for horizontal_offset in 0..2 { + let index = ( + (original_vertical_pos as usize + vertical_offset) + * image_size.width as usize + + (original_horizontal_pos as usize + horizontal_offset) + ) * 3; + + rgb_sum[0] += pixels[index] as u16; // red owo + rgb_sum[1] += pixels[index + 1] as u16; // green owo + rgb_sum[2] += pixels[index + 2] as u16; // blue owo + // this has made me go insane! + } + } + + // work out the index of where the new pixels will lie (destination index). + let destination_index: usize = ((y * new_width + x) * 3) as usize; + + let mut downsampled_pixels = downsampled_pixels.lock().unwrap(); + + downsampled_pixels[destination_index..destination_index + 3].copy_from_slice(&[ + (rgb_sum[0] / 4) as u8, + (rgb_sum[1] / 4) as u8, + (rgb_sum[2] / 4) as u8, + ]); + } + }); + + ( + Arc::try_unwrap(downsampled_pixels) + .expect("Arc unwrap of downsampled pixels failed!") + .into_inner() + .unwrap(), + ( + new_width, + new_height + ) + ) +} \ No newline at end of file diff --git a/src/image/image.rs b/src/image/image.rs new file mode 100644 index 0000000..0f0abc7 --- /dev/null +++ b/src/image/image.rs @@ -0,0 +1,249 @@ +use std::{fs::{self, File}, io::{BufReader, Read}, path::{Path, PathBuf}, sync::{Arc, Mutex}}; + +use log::debug; +use eframe::egui::Vec2; +use imagesize::ImageSize; +use svg_metadata::Metadata; +use display_info::DisplayInfo; +use image::{codecs::{gif::{GifDecoder, GifEncoder}, jpeg::{JpegDecoder, JpegEncoder}, png::{PngDecoder, PngEncoder}, webp::{WebPDecoder, WebPEncoder}}, ExtendedColorType, ImageDecoder, ImageEncoder, ImageResult}; + +use crate::{error::Error, notifier::NotifierAPI}; + +use super::{image_formats::ImageFormat, optimization::ImageOptimization}; + +#[derive(Clone)] +pub struct Image { + pub image_size: ImageSize, + pub image_format: ImageFormat, + pub image_path: Arc, + pub image_bytes: Arc>>>, + // Look! I know you see that type above me but just + // so you know, I'm NOT crazy... well not yet at least... + // + // Anyways, as you can see, `image_bytes` is an `Arc>>>` + // this is because we need to be able to manipulate this under a thread so we can load + // images in a background thread (see https://github.com/cloudy-org/roseate/issues/24). + // + // The first Arc allows us to share the SAME image_bytes safely across threads even when we + // image.clone() that bitch, while Mutex ensures that only one thread accesses or modifies the image + // bytes and also SO THE RUST COMPILER CAN SHUT THE FUCK UP.. YES I KNOW THAT IT'S UNSAFE BECAUSE ANOTHER + // THREAD CAN FUCK IT UP BUT YOU DO REALISE MY PROGRAM IS SMART ENOUGH TO NOT DO THAT... uhmmm uhmmm... anyways... + // I use an Option because an image that is not yet loaded will have no bytes in memory and the second Arc is there + // so we can image.clone() and not be doubling the image bytes in memory and turn into the next Google Chrome web browser. 💀 + // + // Kind regards, + // Goldy +} + +impl Image { + // TODO: Return result instead of panicking (e.g. right now if you + // open an unsupported file type roseate will crash because we panic at line 60). + pub fn from_path(path: &Path) -> Self { + // Changed this to unwrap_or_default so it returns an empty + // string ("") and doesn't panic if a file has no extension. I need to begin adding tests. + let extension = path.extension().unwrap_or_default(); + + let (image_size, image_format) = if extension == "svg" { + ( + get_svg_image_size(&path), + ImageFormat::Svg + ) + } else { + // I use 'imagesize' crate to get the image size and correct image + // format because it's A LOT faster as it only partially loads the image bytes. + + let mut buffer = [0u8; 16]; + let bytes_read = File::open(&path) + .expect("Failed to open file to get image type!") + .read(&mut buffer).unwrap(); + + let image_size_image_type = imagesize::image_type(&buffer[..bytes_read]) + .expect("imagesize crate failed to get image size!"); + + ( + imagesize::size(&path).expect( + "Failed to retrieve the dimensions of the image!" + ), + ImageFormat::from_image_size_crate(image_size_image_type) + .expect("Failed to convert image size image format to roseate's image format!") + ) + }; + + Self { + image_size, + image_format, + image_path: Arc::new(path.to_owned()), + image_bytes: Arc::new(Mutex::new(None)) + } + } + + pub fn load_image(&mut self, optimizations: &[ImageOptimization], notifier: &mut NotifierAPI) -> Result<(), Error> { + if optimizations.is_empty() { + debug!("No optimizations were set so loading with fs::read instead..."); + + let mut image_bytes_lock = self.image_bytes.lock().unwrap(); + + // TODO: return Error instead of panic. + *image_bytes_lock = Some( + Arc::from(fs::read(self.image_path.as_ref()).expect("Failed to read image with fs::read!")) + ); + + return Ok(()); // I avoid image crate here as loading the bytes with fs::read is + // A LOT faster and no optimizations need to be done so we don't need image crate. + } + + let (mut actual_width, mut actual_height) = ( + self.image_size.width as u32, + self.image_size.height as u32 + ); + + notifier.set_loading(Some("Opening file...".into())); + debug!("Opening file into buf reader for image crate to read..."); + + let image_file = match File::open(self.image_path.as_ref()) { + Ok(file) => file, + Err(error) => { + return Err( + Error::FileNotFound( + self.image_path.to_path_buf(), Some(error.to_string()) + ) + ) + }, + }; + + let image_buf_reader = BufReader::new(image_file); // apparently this is faster for larger files as + // it avoids loading files line by line hence less system calls to the disk. (EDIT: I'm defiantly noticing a speed difference) + + notifier.set_loading(Some("Decoding image...".into())); + debug!("Loading image buf reader into image decoder so optimizations can be applied to pixels..."); + + let image_decoder: Box = match self.image_format { + ImageFormat::Png => Box::new(PngDecoder::new(image_buf_reader).unwrap()), + ImageFormat::Jpeg => Box::new(JpegDecoder::new(image_buf_reader).unwrap()), + ImageFormat::Svg => Box::new(PngDecoder::new(image_buf_reader).unwrap()), + ImageFormat::Gif => Box::new(GifDecoder::new(image_buf_reader).unwrap()), + ImageFormat::Webp => Box::new(WebPDecoder::new(image_buf_reader).unwrap()), + }; + + let image_colour_type = image_decoder.color_type(); + + let mut pixels = vec![0; image_decoder.total_bytes() as usize]; + + debug!("Decoding pixels from image..."); + + image_decoder.read_image(&mut pixels).unwrap(); + + for optimization in optimizations { + notifier.set_loading( + Some(format!("Applying {:#} optimization...", optimization)) + ); + debug!("Applying '{:?}' optimization to image...", optimization); + + (pixels, (actual_width, actual_height)) = optimization.apply(pixels, &self.image_size); + } + + let mut optimized_image_buffer: Vec = Vec::new(); + + notifier.set_loading( + Some("Encoding optimized image...".into()) + ); + debug!("Encoding optimized image into image buffer..."); + + let image_result: ImageResult<()> = match self.image_format { + ImageFormat::Png => { + PngEncoder::new(&mut optimized_image_buffer).write_image( + &pixels, + actual_width, + actual_height, + ExtendedColorType::Rgb8 + ) + }, + ImageFormat::Jpeg => { + JpegEncoder::new(&mut optimized_image_buffer).write_image( + &pixels, + actual_width, + actual_height, + image_colour_type.into() + ) + }, + ImageFormat::Svg => { + PngEncoder::new(&mut optimized_image_buffer).write_image( + &pixels, + actual_width, + actual_height, + image_colour_type.into() + ) + }, + ImageFormat::Gif => { + GifEncoder::new(&mut optimized_image_buffer).encode( + &pixels, + actual_width, + actual_height, + image_colour_type.into() + ) + }, + ImageFormat::Webp => { + WebPEncoder::new_lossless(&mut optimized_image_buffer).write_image( + &pixels, + actual_width, + actual_height, + image_colour_type.into() + ) + }, + }; + + if let Err(image_error) = image_result { + let error = Error::FailedToApplyOptimizations( + format!( + "Failed to decode and load image with \ + image crate to apply optimizations! \nError: {}.", + image_error + ) + ); + + // warn the user that optimizations failed to apply. + notifier.toasts.lock().unwrap() + .toast_and_log(error.into(), egui_notify::ToastLevel::Error); + + return self.load_image(&[], notifier); // load image without optimizations + } + + *self.image_bytes.lock().unwrap() = Some(Arc::from(optimized_image_buffer)); + + Ok(()) + } +} + +fn get_primary_display_info() -> DisplayInfo { + let all_display_infos = DisplayInfo::all().expect( + "Failed to get information about your display monitor!" + ); + + // NOTE: I don't think the first monitor is always the primary and + // if that is the case then we're gonna have a problem. (i.e images overly downsampled or not at all) + let primary_display_maybe = all_display_infos.first().expect( + "Uhhhhh, you don't have a monitor. WHAT!" + ); + + primary_display_maybe.clone() +} + +fn get_svg_image_size(path: &Path) -> ImageSize { + let metadata = Metadata::parse_file(path).expect( + "Failed to parse metadata of the svg file!" + ); + + let width = metadata.width().expect("Failed to get SVG width"); + let height = metadata.height().expect("Failed to get SVG height"); + + let display_info = get_primary_display_info(); + + let image_to_display_ratio = Vec2::new(width as f32, height as f32) / + Vec2::new(display_info.width as f32, display_info.height as f32); + + // Temporary solution to give svg images a little bit higher quality. + ImageSize { + width: (width * (1.0 + (1.0 - image_to_display_ratio.x)) as f64) as usize, + height: (height * (1.0 + (1.0 - image_to_display_ratio.y)) as f64) as usize + } +} \ No newline at end of file diff --git a/src/image/image_formats.rs b/src/image/image_formats.rs new file mode 100644 index 0000000..627c0de --- /dev/null +++ b/src/image/image_formats.rs @@ -0,0 +1,34 @@ +use imagesize::ImageType; + +use crate::error::Error; + +#[derive(Clone, Debug)] +pub enum ImageFormat { + Png, + Jpeg, + Svg, + Gif, + Webp +} + +impl ImageFormat { + // NOTE: Add more formats we know will load later. + pub fn from_image_size_crate(image_size_image_type: ImageType) -> Result { + let image_format = match image_size_image_type { + ImageType::Gif => ImageFormat::Gif, + ImageType::Jpeg => ImageFormat::Jpeg, + ImageType::Jxl => ImageFormat::Jpeg, + ImageType::Png => ImageFormat::Png, + ImageType::Webp => ImageFormat::Webp, + unsupported_format => { + return Err( + Error::ImageFormatNotSupported( + format!("{:?}", unsupported_format) + ) + ); + }, + }; + + Ok(image_format) + } +} \ No newline at end of file diff --git a/src/image/mod.rs b/src/image/mod.rs new file mode 100644 index 0000000..4b95f51 --- /dev/null +++ b/src/image/mod.rs @@ -0,0 +1,5 @@ +pub mod image; +pub mod optimization; +pub mod image_formats; + +mod fast_downsample; \ No newline at end of file diff --git a/src/image/optimization.rs b/src/image/optimization.rs new file mode 100644 index 0000000..5914553 --- /dev/null +++ b/src/image/optimization.rs @@ -0,0 +1,88 @@ +use std::fmt::Display; + +use log::debug; +use imagesize::ImageSize; +use display_info::DisplayInfo; + +use super::fast_downsample::fast_downsample; + +#[derive(Debug)] +pub enum ImageOptimization { + /// Downsamples the image to this width and height. + /// + /// Images don't always have to be displayed at it's native resolution, + /// especially when the image is significantly bigger than our monitor + /// can even display so to save memory we downsample the image. Downsampling + /// decreases the memory usage of the image at the cost of time wasted actually + /// resizing the image. The bigger the image the more time it will take to downsample + /// but the memory savings are very valuable. + /// + /// NOTE: "The image's aspect ratio is preserved. The image is scaled to the maximum + /// possible size that fits within the bounds specified by the width and height." ~ Image Crate + Downsample(u32, u32), +} + +impl ImageOptimization { + pub fn apply(&self, pixels: Vec, image_size: &ImageSize) -> (Vec, (u32, u32)) { + match self { + ImageOptimization::Downsample(width, height) => { + // image.resize( + // *width, + // *height, + // image::imageops::FilterType::Lanczos3 + // ) + + fast_downsample(pixels, image_size, (*width, *height)) + }, + } + } +} + +impl Display for ImageOptimization { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImageOptimization::Downsample(_, _) => write!(f, "Downsample"), + } + } +} + +pub fn apply_image_optimizations(mut optimizations: Vec, image_size: &ImageSize) -> Vec { + let all_display_infos = DisplayInfo::all().expect( + "Failed to get information about your display monitor!" + ); + + // NOTE: I don't think the first monitor is always the primary and + // if that is the case then we're gonna have a problem. (i.e images overly downsampled or not at all) + let primary_display_maybe = all_display_infos.first().expect( + "Uhhhhh, you don't have a monitor. WHAT!" + ); + + let marginal_allowance: f32 = 1.3; // TODO: Make this adjustable in the config too as down sample strength. + + let (width, height) = ( + primary_display_maybe.width as f32 * marginal_allowance, + primary_display_maybe.height as f32 * marginal_allowance + ); + + debug!( + "Display Size: {} x {}", + primary_display_maybe.width, + primary_display_maybe.height + ); + debug!( + "Display Size + Downsample Marginal Allowance: {} x {}", width, height + ); + debug!( + "Image Size: {} x {}", + image_size.width, + image_size.height + ); + + // If the image is a lot bigger than the user's monitor + // then apply the downsample optimization for this image. + if image_size.width > width as usize && image_size.height > height as usize { + optimizations.push(ImageOptimization::Downsample(width as u32, height as u32)); + } + + optimizations +} \ No newline at end of file diff --git a/src/image_loader.rs b/src/image_loader.rs index 65bda2d..4a213c3 100644 --- a/src/image_loader.rs +++ b/src/image_loader.rs @@ -1,68 +1,36 @@ use std::{sync::{Arc, Mutex}, thread, time::Duration}; -use egui_notify::Toast; use log::{debug, warn}; -use crate::{image::{apply_image_optimizations, Image}, toasts::ToastsManager}; - -#[derive(Default, Clone)] -pub struct Loading { - pub message: Option -} - -// just wanted to play around with these, see how I use them below -macro_rules! loading_msg { - ($message_string: expr, $image_loading_arc: ident) => { - { - *$image_loading_arc.lock().unwrap() = Some( - Loading { - message: Some($message_string.into()) - } - ); - } - } -} +use crate::{image::{image::Image, optimization::apply_image_optimizations}, notifier::NotifierAPI}; /// Struct that handles all the image loading logic in a thread safe /// manner to allow features such as background image loading / lazy loading. pub struct ImageLoader { - toasts_queue_arc: Arc>>, - pub image_loaded: bool, + image_loaded_arc: Arc>, - pub image_loading: Option, - image_loading_arc: Arc>>, + image_loading: bool, } impl ImageLoader { pub fn new() -> Self { Self { - toasts_queue_arc: Arc::new(Mutex::new(Vec::new())), image_loaded: false, image_loaded_arc: Arc::new(Mutex::new(false)), - image_loading: None, - image_loading_arc: Arc::new(Mutex::new(None)), + image_loading: false, } } - pub fn update(&mut self, toasts: &mut ToastsManager) { + pub fn update(&mut self) { // I use an update function to keep the public fields update to date with their Arc> twins. // // I also use this to append the queued toast messages // from threads as we cannot take ownership of "&mut Toasts" sadly. - if let Ok(value) = self.image_loading_arc.try_lock() { - self.image_loading = value.clone(); // TODO: find a way to reference instead of clone to save memory here. - } - if let Ok(value) = self.image_loaded_arc.try_lock() { self.image_loaded = value.clone(); // TODO: find a way to reference instead of clone to save memory here. - } - - if let Ok(mut queue) = self.toasts_queue_arc.try_lock() { - for toast in queue.drain(..) { - toasts.toasts.add(toast); - } + self.image_loading = false; } } @@ -70,62 +38,55 @@ impl ImageLoader { /// Set `lazy_load` to `true` if you want the image to be loaded in the background on a separate thread. /// /// Setting `lazy_load` to `false` **will block the main thread** until the image is loaded. - pub fn load_image(&mut self, image: &mut Image, lazy_load: bool) { - if self.image_loading_arc.lock().unwrap().is_some() { + pub fn load_image(&mut self, image: &mut Image, lazy_load: bool, notifier: &mut NotifierAPI) { + if self.image_loading { warn!("Not loading image as one is already being loaded!"); return; } - *self.image_loading_arc.lock().unwrap() = Some( - Loading { - message: Some("Preparing to load image...".into()) - } + self.image_loading = true; + + notifier.set_loading( + Some("Preparing to load image...".into()) ); let mut image = image.clone(); // Our svg implementation is very experimental. Let's warn the user. if image.image_path.extension().unwrap_or_default() == "svg" { - let msg = "SVG files are experimental! \ - Expect many bugs, inconstancies and performance issues."; - - let mut toast = Toast::warning(msg); - toast.duration(Some(Duration::from_secs(8))); - - self.toasts_queue_arc.lock().unwrap().push(toast); - - warn!("{}", msg); + notifier.toasts.lock().unwrap() + .toast_and_log( + "SVG files are experimental! \ + Expect many bugs, inconstancies and performance issues.".into(), + egui_notify::ToastLevel::Warning + ) + .duration(Some(Duration::from_secs(8))); } - let toasts_queue_arc = self.toasts_queue_arc.clone(); let image_loaded_arc = self.image_loaded_arc.clone(); - let image_loading_arc = self.image_loading_arc.clone(); + let mut notifier_arc = notifier.clone(); let mut loading_logic = move || { let mut optimizations = Vec::new(); - loading_msg!("Applying image optimizations...", image_loading_arc); + notifier_arc.set_loading( + Some("Applying image optimizations...".into()) + ); optimizations = apply_image_optimizations(optimizations, &image.image_size); - loading_msg!("Loading image...", image_loading_arc); - let result = image.load_image(&optimizations); + notifier_arc.set_loading( + Some("Loading image...".into()) + ); + let result = image.load_image(&optimizations, &mut notifier_arc); if let Err(error) = result { - let mut toast = Toast::error( - textwrap::wrap(&error.message(), 100).join("\n") - ); - toast.duration(Some(Duration::from_secs(10))); - - toasts_queue_arc.lock().unwrap().push(toast); - - log::error!("{}", error.message()); + notifier_arc.toasts.lock().unwrap() + .toast_and_log(error.into(), egui_notify::ToastLevel::Error) + .duration(Some(Duration::from_secs(10))); } - let mut image_loaded = image_loaded_arc.lock().unwrap(); - let mut image_loading = image_loading_arc.lock().unwrap(); - - *image_loaded = true; - *image_loading = None; + notifier_arc.unset_loading(); + *image_loaded_arc.lock().unwrap() = true; }; if lazy_load { diff --git a/src/info_box.rs b/src/info_box.rs index 82a681e..7ebfb71 100644 --- a/src/info_box.rs +++ b/src/info_box.rs @@ -4,7 +4,7 @@ use cap::Cap; use egui_notify::ToastLevel; use eframe::egui::{self, pos2, Key, Margin, Response}; -use crate::{config::config::Config, image::Image, toasts::ToastsManager}; +use crate::{config::config::Config, image::image::Image, notifier::NotifierAPI}; #[global_allocator] static ALLOCATOR: Cap = Cap::new(alloc::System, usize::max_value()); @@ -17,11 +17,11 @@ pub struct InfoBox { } impl InfoBox { - pub fn new(config: &Config, toasts: &mut ToastsManager) -> Self { + pub fn new(config: &Config, notifier: &mut NotifierAPI) -> Self { let config_key = match Key::from_name(&config.key_binds.info_box.toggle) { Some(key) => key, None => { - toasts.toast_and_log( + notifier.toasts.lock().unwrap().toast_and_log( "The key bind set for 'info_box.toggle' is invalid! Defaulting to `I`.".into(), ToastLevel::Error ); diff --git a/src/magnification_panel.rs b/src/magnification_panel.rs index 94c8afd..308aceb 100644 --- a/src/magnification_panel.rs +++ b/src/magnification_panel.rs @@ -1,7 +1,7 @@ use eframe::egui::{self, Key, Vec2}; use egui_notify::ToastLevel; -use crate::{config::config::Config, toasts::ToastsManager, zoom_pan::ZoomPan}; +use crate::{config::config::Config, notifier::NotifierAPI, zoom_pan::ZoomPan}; pub struct MagnificationPanel { pub show: bool, @@ -11,11 +11,11 @@ pub struct MagnificationPanel { impl MagnificationPanel { // TODO: When this branch is merged into main // remove "image" from the initialization of this struct. - pub fn new(config: &Config, toasts: &mut ToastsManager) -> Self { + pub fn new(config: &Config, notifier: &mut NotifierAPI) -> Self { let toggle_key = match Key::from_name(&config.key_binds.ui_controls.toggle) { Some(key) => key, None => { - toasts.toast_and_log( + notifier.toasts.lock().unwrap().toast_and_log( "The key bind set for 'ui_controls.toggle' is invalid! Defaulting to `C`.".into(), ToastLevel::Error ); diff --git a/src/main.rs b/src/main.rs index 064c744..0ec6cf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,17 +9,17 @@ use egui_notify::ToastLevel; use cirrus_theming::v1::Theme; use clap::{arg, command, Parser}; -use app::Roseate; -use image::Image; use error::Error; -use toasts::ToastsManager; +use app::Roseate; +use image::image::Image; +use notifier::NotifierAPI; mod app; mod files; mod image; mod error; mod config; -mod toasts; +mod notifier; mod info_box; mod zoom_pan; mod image_loader; @@ -50,7 +50,7 @@ fn main() -> eframe::Result { // error and exit without visually notifying the user // hence I have brought toasts outside the scope of app::Roseate // so we can queue up notifications when things go wrong here. - let mut toasts = ToastsManager::new(); + let notifier = NotifierAPI::new(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -73,9 +73,9 @@ fn main() -> eframe::Result { let path = Path::new(&path); if !path.exists() { - let error = Error::FileNotFound(path.to_path_buf()); + let error = Error::FileNotFound(path.to_path_buf(), None); - toasts.toast_and_log( + notifier.toasts.lock().unwrap().toast_and_log( error.into(), ToastLevel::Error ).duration(Some(Duration::from_secs(10))); @@ -108,7 +108,7 @@ fn main() -> eframe::Result { Ok(config) => config, Err(error) => { - toasts.toast_and_log( + notifier.toasts.lock().unwrap().toast_and_log( format!( "Error occurred getting roseate's config file! \ Defaulting to default config. Error: {}", error.to_string().as_str() @@ -125,7 +125,7 @@ fn main() -> eframe::Result { options, Box::new(|cc| { egui_extras::install_image_loaders(&cc.egui_ctx); - Ok(Box::new(Roseate::new(image, theme, toasts, config))) + Ok(Box::new(Roseate::new(image, theme, notifier, config))) }), ) } \ No newline at end of file diff --git a/src/toasts.rs b/src/notifier.rs similarity index 64% rename from src/toasts.rs rename to src/notifier.rs index 22b11f8..27ed99d 100644 --- a/src/toasts.rs +++ b/src/notifier.rs @@ -1,37 +1,21 @@ +use std::sync::{Arc, Mutex, RwLock}; + use eframe::egui::Context; use egui_notify::{Toast, ToastLevel, Toasts}; +use log::info; use crate::error::Error; -#[derive(Clone)] -pub enum StringOrError { - Error(Error), - String(String), -} - -impl Into for Error { - fn into(self) -> StringOrError { - StringOrError::String(self.message()) - } -} - -impl Into for String { - fn into(self) -> StringOrError { - StringOrError::String(self) - } -} - -impl Into for &str { - fn into(self) -> StringOrError { - StringOrError::String(self.to_string()) - } +#[derive(Default, Clone)] +pub struct Loading { + pub message: Option } +#[derive(Default)] pub struct ToastsManager { - pub toasts: Toasts, + pub toasts: Toasts } -// Struct that brings an interface to manage toasts. impl ToastsManager { pub fn new() -> Self { Self { @@ -75,4 +59,66 @@ impl ToastsManager { StringOrError::String(string) => string, } } +} + +#[derive(Default, Clone)] +pub struct NotifierAPI { + pub toasts: Arc>, + pub loading_status: Arc>>, +} + +// Struct that brings an interface to manage toasts. +impl NotifierAPI { + pub fn new() -> Self { + Self { + toasts: Arc::new(Mutex::new(ToastsManager::new())), + loading_status: Arc::new(RwLock::new(None)), + } + } + + pub fn update(&mut self, ctx: &Context) { + if let Ok(mut toasts) = self.toasts.try_lock() { + toasts.update(ctx); + } + } + + pub fn set_loading(&mut self, message: Option) { + *self.loading_status.write().unwrap() = Some(Loading { message }) + } + + pub fn set_loading_and_log(&mut self, message: Option) { + if let Some(message) = &message { + info!("{}", message); + } + + self.set_loading(message); + } + + pub fn unset_loading(&mut self) { + *self.loading_status.write().unwrap() = None; + } +} + +#[derive(Clone)] +pub enum StringOrError { + Error(Error), + String(String), +} + +impl Into for Error { + fn into(self) -> StringOrError { + StringOrError::String(self.message()) + } +} + +impl Into for String { + fn into(self) -> StringOrError { + StringOrError::String(self) + } +} + +impl Into for &str { + fn into(self) -> StringOrError { + StringOrError::String(self.to_string()) + } } \ No newline at end of file diff --git a/src/zoom_pan.rs b/src/zoom_pan.rs index 8b7c143..aaa727c 100644 --- a/src/zoom_pan.rs +++ b/src/zoom_pan.rs @@ -5,7 +5,7 @@ use rand::Rng; use log::debug; use eframe::egui::{Context, Key, Pos2, Response, Vec2}; -use crate::{config::config::Config, toasts::ToastsManager}; +use crate::{config::config::Config, notifier::NotifierAPI}; /// Struct that controls the zoom and panning of the image. pub struct ZoomPan { @@ -27,11 +27,11 @@ struct ResetManager { } impl ZoomPan { - pub fn new(config: &Config, toasts: &mut ToastsManager) -> Self { + pub fn new(config: &Config, notifier: &mut NotifierAPI) -> Self { let reset_key = match Key::from_name(&config.key_binds.image.reset_pos) { Some(key) => key, None => { - toasts.toast_and_log( + notifier.toasts.lock().unwrap().toast_and_log( "The key bind set for 'image.reset_pos' is invalid! Defaulting to `R`.".into(), ToastLevel::Error );