From d99daad42eab483e3967dec77acdeba0e2b0b1f3 Mon Sep 17 00:00:00 2001 From: Ananas Date: Mon, 4 Nov 2024 12:13:38 +0100 Subject: [PATCH 1/8] feat: support svg images --- Cargo.toml | 1 + src/image.rs | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a3fda5c..f7f2663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ clap = {version = "4.5.20", features = ["derive"]} rand = "0.8.5" display-info = "0.5.1" egui-notify = "0.17.0" +svg_metadata = "0.5.1" [profile.dev.package."*"] opt-level = 3 diff --git a/src/image.rs b/src/image.rs index 2e4813b..7181ee3 100644 --- a/src/image.rs +++ b/src/image.rs @@ -4,6 +4,7 @@ use display_info::DisplayInfo; use log::debug; use image::{ImageFormat, ImageReader}; use imagesize::ImageSize; +use svg_metadata::Metadata; #[derive(Clone)] pub struct Image { @@ -30,11 +31,25 @@ pub enum ImageOptimization { impl Image { pub fn from_path(path: &Path) -> Self { - // I use imagesize crate to get the image size - // because it's A LOT faster as it only partially loads the image bytes. - let image_size = imagesize::size(path).expect( - "Failed to retrieve the dimensions of the image!" - ); + let extension = path.extension().expect("The given file has no extension."); + + let image_size: ImageSize = if extension == "svg" { + 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"); + + ImageSize { + width: width as usize, + height: height as usize + } + } else { + imagesize::size(path).expect( + "Failed to retrieve the dimensions of the image!" + ) + }; Self { image_size, From 3bae4f34505228883f52603ee31c39dac631b7b9 Mon Sep 17 00:00:00 2001 From: Ananas Date: Mon, 4 Nov 2024 18:12:13 +0100 Subject: [PATCH 2/8] feat: add svg to "select_image" --- src/files.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/files.rs b/src/files.rs index e11283b..5f63158 100644 --- a/src/files.rs +++ b/src/files.rs @@ -5,7 +5,7 @@ use crate::image::Image; pub fn select_image() -> Result { let image_path = FileDialog::new() - .add_filter("images", &["png", "jpeg", "jpg", "webp", "gif"]) + .add_filter("images", &["png", "jpeg", "jpg", "webp", "gif", "svg"]) .pick_file(); let image_or_error = match image_path { From a7601193a6d5e0c8cf40bab14dee469d5caafd23 Mon Sep 17 00:00:00 2001 From: Goldy <66202304+THEGOLDENPRO@users.noreply.github.com> Date: Mon, 4 Nov 2024 20:34:35 +0000 Subject: [PATCH 3/8] refactor: stroke is not needed --- src/app.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index df2a5b5..86168e2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -64,7 +64,6 @@ impl eframe::App for Roseate { let rose_width: f32 = 130.0; egui::Frame::default() - .stroke(Stroke::default()) .outer_margin( // I adjust the margin as it's the only way I know to // narrow down the interactive part (clickable part) of the rose image. From 12ad13214c2cf2a76c9f1d12b836940fafd70e1a Mon Sep 17 00:00:00 2001 From: Goldy <66202304+THEGOLDENPRO@users.noreply.github.com> Date: Mon, 4 Nov 2024 20:50:17 +0000 Subject: [PATCH 4/8] refactor: move svg image size code to a separate function as par my suggestion in #13 --- src/app.rs | 2 +- src/image.rs | 39 +++++++++++++++++++++++---------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/app.rs b/src/app.rs index 86168e2..0661205 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,7 @@ use std::time::Duration; use cirrus_theming::Theme; -use eframe::egui::{self, Color32, CursorIcon, ImageSource, Margin, Rect, Stroke, Vec2}; +use eframe::egui::{self, Color32, CursorIcon, ImageSource, Margin, Rect, Vec2}; use egui_notify::Toasts; use crate::{error, files, image::{apply_image_optimizations, Image}, info_box::InfoBox, window_scaling::WindowScaling, zoom_pan::ZoomPan}; diff --git a/src/image.rs b/src/image.rs index 7181ee3..e238716 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,10 +1,10 @@ use std::{fs::{self, File}, io::{BufReader, Cursor}, path::{Path, PathBuf}, sync::Arc}; -use display_info::DisplayInfo; use log::debug; -use image::{ImageFormat, ImageReader}; use imagesize::ImageSize; use svg_metadata::Metadata; +use display_info::DisplayInfo; +use image::{ImageFormat, ImageReader}; #[derive(Clone)] pub struct Image { @@ -31,21 +31,15 @@ pub enum ImageOptimization { impl Image { pub fn from_path(path: &Path) -> Self { - let extension = path.extension().expect("The given file has no extension."); - - let image_size: ImageSize = if extension == "svg" { - let metadata = Metadata::parse_file(path).expect( - "Failed to parse metadata of the svg file!" - ); + // 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 width = metadata.width().expect("Failed to get SVG width"); - let height = metadata.height().expect("Failed to get SVG height"); - - ImageSize { - width: width as usize, - height: height as usize - } + 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!" ) @@ -109,7 +103,6 @@ impl Image { self.image_bytes = Some(Arc::from(buffer)); } - } // NOTE: should this be here? Don't know. @@ -152,4 +145,18 @@ pub fn apply_image_optimizations(mut optimizations: Vec, imag } optimizations +} + +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"); + + ImageSize { + width: width as usize, + height: height as usize + } } \ No newline at end of file From ff61ebdde293da36d8e13daf0339480108785ac4 Mon Sep 17 00:00:00 2001 From: Goldy <66202304+THEGOLDENPRO@users.noreply.github.com> Date: Tue, 5 Nov 2024 00:59:33 +0000 Subject: [PATCH 5/8] feat: temporary solution to give svg images a little bit higher quality and improve overall error handling. --- Cargo.toml | 1 + src/app.rs | 9 +++++++-- src/error.rs | 51 +++++++++++++++++++++++++++++++++++++++++----- src/image.rs | 57 +++++++++++++++++++++++++++++++++++++++++++--------- src/main.rs | 13 ++++++++++-- 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f7f2663..9c3f8f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ rand = "0.8.5" display-info = "0.5.1" egui-notify = "0.17.0" svg_metadata = "0.5.1" +textwrap = "0.16.1" [profile.dev.package."*"] opt-level = 3 diff --git a/src/app.rs b/src/app.rs index 0661205..d33b282 100644 --- a/src/app.rs +++ b/src/app.rs @@ -91,7 +91,7 @@ impl eframe::App for Roseate { self.info_box = InfoBox::new(Some(image.clone()), self.theme.clone()); }, Err(error) => { - error::log_and_toast(error, &mut self.toasts) + error::log_and_toast(error.into(), &mut self.toasts) .duration(Some(Duration::from_secs(5))); }, } @@ -112,7 +112,12 @@ impl eframe::App for Roseate { let mut optimizations = Vec::new(); optimizations = apply_image_optimizations(optimizations, &mutable_image.image_size); - mutable_image.load_image(&optimizations); + let result = mutable_image.load_image(&optimizations); + + if let Err(error) = result { + error::log_and_toast(error.into(), &mut self.toasts) + .duration(Some(Duration::from_secs(10))); + } self.image_loaded = true; } diff --git a/src/error.rs b/src/error.rs index f14f949..322f122 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,7 +5,9 @@ use egui_notify::{Toast, Toasts}; #[derive(Debug)] pub enum Error { FileNotFound(PathBuf), - NoFileSelected + NoFileSelected, + FailedToApplyOptimizations(String), + ImageFormatNotSupported(String), } impl Error { @@ -25,13 +27,52 @@ impl Display for Error { Error::NoFileSelected => write!( f, "No file was selected in the file dialogue!" ), + Error::FailedToApplyOptimizations(technical_reason) => write!( + f, + "Failed to apply optimizations to this image! \ + Roseate will run slower than usual and use a lot more memory \ + possibly leading to system crashes. BEWARE! \n\nTechnical Reason: {}", + technical_reason + ), + Error::ImageFormatNotSupported(image_format) => write!( + f, "The image format '{}' is not supported!", image_format + ), } } } -pub fn log_and_toast(error: Error, toasts: &mut Toasts) -> &mut Toast { - log::error!("{}", error); +pub enum LogAndToastError { + Error(Error), + String(String) +} + +impl Into for Error { + fn into(self) -> LogAndToastError { + LogAndToastError::String(self.message()) + } +} + +impl Into for String { + fn into(self) -> LogAndToastError { + LogAndToastError::String(self) + } +} + +impl Into for &str { + fn into(self) -> LogAndToastError { + LogAndToastError::String(self.to_string()) + } +} + +pub fn log_and_toast(error_or_string: LogAndToastError, toasts: &mut Toasts) -> &mut Toast { + let error_message = match error_or_string { + LogAndToastError::Error(error) => error.message(), + LogAndToastError::String(string) => string, + }; + + log::error!("{}", error_message); - toasts.error(error.message()) - .duration(Some(Duration::from_secs(5))) + toasts.error( + textwrap::wrap(error_message.as_str(), 100).join("\n") + ).duration(Some(Duration::from_secs(5))) } \ No newline at end of file diff --git a/src/image.rs b/src/image.rs index e238716..152323f 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,11 +1,14 @@ use std::{fs::{self, File}, io::{BufReader, Cursor}, path::{Path, PathBuf}, sync::Arc}; 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, @@ -52,14 +55,14 @@ impl Image { } } - pub fn load_image(&mut self, optimizations: &[ImageOptimization]) { + 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..."); self.image_bytes = Some( Arc::from(fs::read(self.image_path.as_ref()).expect("Failed to read image with fs::read!")) ); - return; // I avoid image crate here as loading the bytes with fs::read is + 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. } @@ -69,14 +72,29 @@ impl Image { &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 notice a speed difference) + // 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 mut image = ImageReader::new(image_buf_reader) - .with_guessed_format().unwrap().decode().expect( - "Failed to decode and load image with image crate to apply optimizations!" - ); + let image_result = ImageReader::new(image_buf_reader) + .with_guessed_format() + .unwrap() + .decode(); + + if let Err(image_error) = image_result { + let _ = self.load_image(&[]); // load image without optimizations + 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); @@ -102,6 +120,7 @@ impl Image { ); self.image_bytes = Some(Arc::from(buffer)); + Ok(()) } } @@ -147,6 +166,20 @@ pub fn apply_image_optimizations(mut optimizations: Vec, imag 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!" @@ -155,8 +188,14 @@ fn get_svg_image_size(path: &Path) -> ImageSize { 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 as usize, - height: height as usize + 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/main.rs b/src/main.rs index 3459ca9..1c578fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::{env, path::Path, time::Duration}; use log::debug; use eframe::egui; -use egui_notify::Toasts; +use egui_notify::{ToastLevel, Toasts}; use cirrus_theming::Theme; use clap::{arg, command, Parser}; @@ -67,11 +67,20 @@ fn main() -> eframe::Result { if !path.exists() { let error = Error::FileNotFound(path.to_path_buf()); - log_and_toast(error, &mut toasts) + log_and_toast(error.into(), &mut toasts) .duration(Some(Duration::from_secs(10))); None } else { + // Our svg implementation is very experimental. Let's warn the user. + if path.extension().unwrap_or_default() == "svg" { + log_and_toast( + "SVG files are experimental! \ + Expect many bugs, inconstancies and performance issues.".into(), + &mut toasts + ).level(ToastLevel::Warning).duration(Some(Duration::from_secs(8))); + } + Some(Image::from_path(path)) } }, From 52b14323cd73c1e5b721a8b64a4f526eae799587 Mon Sep 17 00:00:00 2001 From: Goldy <66202304+THEGOLDENPRO@users.noreply.github.com> Date: Tue, 5 Nov 2024 01:18:30 +0000 Subject: [PATCH 6/8] chore: version bump --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9c3f8f4..57abf2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "roseate" edition = "2021" -version = "0.1.0-alpha.11" +version = "0.1.0-alpha.12" description = "A small and simple but fancy image viewer built with Rust that's cross platform." authors = ["Goldy "] license = "GPL-3.0" From 304360fb19491b94a58a450dc364eee250777386 Mon Sep 17 00:00:00 2001 From: Goldy <66202304+THEGOLDENPRO@users.noreply.github.com> Date: Mon, 11 Nov 2024 23:30:51 +0000 Subject: [PATCH 7/8] feat: background image loading (lazy loading) and loading animation, fixes #24 --- src/app.rs | 161 +++++++++++++++++++++++++------------------- src/image.rs | 38 +++++++++-- src/image_loader.rs | 126 ++++++++++++++++++++++++++++++++++ src/info_box.rs | 21 ++++-- src/main.rs | 1 + 5 files changed, 268 insertions(+), 79 deletions(-) create mode 100644 src/image_loader.rs diff --git a/src/app.rs b/src/app.rs index d33b282..c942a9a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,10 @@ use std::time::Duration; -use cirrus_theming::Theme; -use eframe::egui::{self, Color32, CursorIcon, ImageSource, Margin, Rect, Vec2}; use egui_notify::Toasts; +use cirrus_theming::Theme; +use eframe::egui::{self, Align, Color32, CursorIcon, Frame, ImageSource, Layout, Margin, Rect, Style, TextStyle, Vec2}; -use crate::{error, files, image::{apply_image_optimizations, Image}, info_box::InfoBox, window_scaling::WindowScaling, zoom_pan::ZoomPan}; +use crate::{error, files, image::Image, image_loader::ImageLoader, info_box::InfoBox, window_scaling::WindowScaling, zoom_pan::ZoomPan}; pub struct Roseate { theme: Theme, @@ -13,23 +13,27 @@ pub struct Roseate { zoom_pan: ZoomPan, info_box: InfoBox, window_scaling: WindowScaling, - last_window_rect: Rect, - image_loaded: bool + image_loader: ImageLoader, + last_window_rect: Rect } impl Roseate { pub fn new(image: Option, theme: Theme, toasts: Toasts) -> Self { - let (ib_image, ib_theme) = (image.clone(), theme.clone()); + let mut image_loader = ImageLoader::new(); + + if image.is_some() { + image_loader.load_image(&mut image.clone().unwrap(), false); + } Self { image, theme, toasts: toasts, zoom_pan: ZoomPan::new(), - info_box: InfoBox::new(ib_image, ib_theme), + info_box: InfoBox::new(), window_scaling: WindowScaling::new(), - last_window_rect: Rect::NOTHING, - image_loaded: false + image_loader: image_loader, + last_window_rect: Rect::NOTHING } } } @@ -38,11 +42,14 @@ impl eframe::App for Roseate { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let central_panel_frame = egui::Frame { - inner_margin: Margin::same(5.0), fill: Color32::from_hex(&self.theme.hex_code).unwrap(), // I mean... it should not fail... we know it's a valid hex colour... ..Default::default() }; + ctx.set_style(Style {override_text_style: Some(TextStyle::Monospace), ..Default::default()}); + + self.info_box.init(&self.image, &self.theme); + self.info_box.handle_input(ctx); self.zoom_pan.handle_zoom_input(ctx); self.zoom_pan.handle_reset_input(ctx); @@ -85,10 +92,10 @@ impl eframe::App for Roseate { let image_result = files::select_image(); match image_result { - Ok(image) => { - // TODO: Need to improve this. Possibly by introducing an init function for info box . + Ok(mut image) => { self.image = Some(image.clone()); - self.info_box = InfoBox::new(Some(image.clone()), self.theme.clone()); + // TODO: Use config's value for lazy load instead. + self.image_loader.load_image(&mut image, true); }, Err(error) => { error::log_and_toast(error.into(), &mut self.toasts) @@ -100,72 +107,90 @@ impl eframe::App for Roseate { ); }); - return; + return; // don't do anything else, you know, like stop right there bitch } self.info_box.update(ctx); self.zoom_pan.update(ctx); - - if !self.image_loaded { - let mutable_image = self.image.as_mut().unwrap(); - - let mut optimizations = Vec::new(); - optimizations = apply_image_optimizations(optimizations, &mutable_image.image_size); - - let result = mutable_image.load_image(&optimizations); - - if let Err(error) = result { - error::log_and_toast(error.into(), &mut self.toasts) - .duration(Some(Duration::from_secs(10))); - } - - self.image_loaded = true; - } + self.image_loader.update(&mut self.toasts); let image = self.image.clone().unwrap(); - self.window_scaling.update(&window_rect, &image.image_size); - - ui.centered_and_justified(|ui| { - let scaled_image_size = self.window_scaling.relative_image_size( - Vec2::new(image.image_size.width as f32, image.image_size.height as f32) - ); - - if self.zoom_pan.is_pan_out_of_bounds(scaled_image_size) { - self.zoom_pan.schedule_pan_reset(Duration::from_millis(300)); - }; - - // NOTE: umm do we move this to window scaling... *probably* if we - // want to stay consistent with zoom_pan but this isn't important right now. - let scaled_image_width_animated = egui_animation::animate_eased( - ctx, "image_scale_width", scaled_image_size.x, 1.5, simple_easing::cubic_in_out - ) as u32 as f32; - let scaled_image_height_animated = egui_animation::animate_eased( - ctx, "image_scale_height", scaled_image_size.y, 1.5, simple_easing::cubic_in_out - ) as u32 as f32; - - let scaled_image_size = Vec2::new(scaled_image_width_animated, scaled_image_height_animated); - - let zoom_scaled_image_size = self.zoom_pan.relative_image_size(scaled_image_size); - let image_position = ui.max_rect().center() - zoom_scaled_image_size * 0.5 + self.zoom_pan.pan_offset; - - let zoom_pan_rect = Rect::from_min_size(image_position, zoom_scaled_image_size); - - let response = ui.allocate_rect(zoom_pan_rect, egui::Sense::hover()); - - egui::Image::from_bytes( - format!("bytes://{}", image.image_path.to_string_lossy()), image.image_bytes.unwrap() - ).rounding(10.0) - .paint_at(ui, zoom_pan_rect); + if self.image_loader.image_loaded { + ui.centered_and_justified(|ui| { + let scaled_image_size = self.window_scaling.relative_image_size( + Vec2::new(image.image_size.width as f32, image.image_size.height as f32) + ); + + if self.zoom_pan.is_pan_out_of_bounds(scaled_image_size) { + self.zoom_pan.schedule_pan_reset(Duration::from_millis(300)); + }; + + // NOTE: umm do we move this to window scaling... *probably* if we + // want to stay consistent with zoom_pan but this isn't important right now. + let scaled_image_width_animated = egui_animation::animate_eased( + ctx, "image_scale_width", scaled_image_size.x, 1.5, simple_easing::cubic_in_out + ) as u32 as f32; + let scaled_image_height_animated = egui_animation::animate_eased( + ctx, "image_scale_height", scaled_image_size.y, 1.5, simple_easing::cubic_in_out + ) as u32 as f32; + + let scaled_image_size = Vec2::new(scaled_image_width_animated, scaled_image_height_animated); + + let zoom_scaled_image_size = self.zoom_pan.relative_image_size(scaled_image_size); + let image_position = ui.max_rect().center() - zoom_scaled_image_size * 0.5 + self.zoom_pan.pan_offset; + + let zoom_pan_rect = Rect::from_min_size(image_position, zoom_scaled_image_size); + + let response = ui.allocate_rect(zoom_pan_rect, egui::Sense::hover()); + + egui::Image::from_bytes( + format!( + "bytes://{}", image.image_path.to_string_lossy() + ), + // we can unwrap because we know the bytes exist thanks to 'self.image_loader.image_loaded'. + image.image_bytes.lock().unwrap().clone().unwrap() + ).rounding(10.0) + .paint_at(ui, zoom_pan_rect); + + self.zoom_pan.handle_pan_input(ctx, &response, self.info_box.response.as_ref()); + }); - self.zoom_pan.handle_pan_input(ctx, &response, self.info_box.response.as_ref()); - }); + // We must update the WindowScaling with the window size AFTER + // the image has loaded to maintain that smooth scaling animation. + self.window_scaling.update(&window_rect, &image.image_size); - ctx.request_repaint_after_secs(0.5); // We need to request repaints just in - // just in case one doesn't happen when the window is resized in a certain circumstance - // (i.e. the user maximizes the window and doesn't interact with it). I'm not sure how else we can fix it. + ctx.request_repaint_after_secs(0.5); // We need to request repaints just in + // just in case one doesn't happen when the window is resized in a certain circumstance + // (i.e. the user maximizes the window and doesn't interact with it). I'm not sure how else we can fix it. + } }); + // This is deliberately placed after the central panel so the central panel + // can take up all the space essentially ignoring the space this panel would otherwise take. + // Check out the egui docs for more clarification: https://docs.rs/egui/0.29.1/egui/containers/panel/struct.CentralPanel.html + egui::TopBottomPanel::bottom("status_bar") + .show_separator_line(false) + .frame( + 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); + } + }); + } + } + ); + } } diff --git a/src/image.rs b/src/image.rs index 152323f..e637b35 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,4 +1,4 @@ -use std::{fs::{self, File}, io::{BufReader, Cursor}, path::{Path, PathBuf}, sync::Arc}; +use std::{fs::{self, File}, io::{BufReader, Cursor}, path::{Path, PathBuf}, sync::{Arc, Mutex}}; use log::debug; use eframe::egui::Vec2; @@ -13,7 +13,23 @@ use crate::error::Error; pub struct Image { pub image_size: ImageSize, pub image_path: Arc, - pub image_bytes: Option> + 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)] @@ -51,7 +67,7 @@ impl Image { Self { image_size, image_path: Arc::new(path.to_owned()), - image_bytes: None + image_bytes: Arc::new(Mutex::new(None)) } } @@ -59,9 +75,12 @@ impl Image { if optimizations.is_empty() { debug!("No optimizations were set so loading with fs::read instead..."); - self.image_bytes = Some( + 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. } @@ -82,7 +101,12 @@ impl Image { .decode(); if let Err(image_error) = image_result { - let _ = self.load_image(&[]); // load image without optimizations + let result_of_second_load = self.load_image(&[]); // load image without optimizations + + if let Err(error) = result_of_second_load { + return Err(error); + } + return Err( Error::FailedToApplyOptimizations( format!( @@ -119,7 +143,9 @@ impl Image { "Failed to write optimized image to buffer!" ); - self.image_bytes = Some(Arc::from(buffer)); + let mut image_bytes_lock = self.image_bytes.lock().unwrap(); + *image_bytes_lock = Some(Arc::from(buffer)); + Ok(()) } } diff --git a/src/image_loader.rs b/src/image_loader.rs new file mode 100644 index 0000000..576fac0 --- /dev/null +++ b/src/image_loader.rs @@ -0,0 +1,126 @@ +use std::{sync::{Arc, Mutex}, thread, time::Duration}; + +use egui_notify::{Toast, Toasts}; +use log::{debug, warn}; + +use crate::image::{apply_image_optimizations, Image}; + +#[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()) + } + ); + } + } +} + +/// 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>>, +} + +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)) + } + } + + pub fn update(&mut self, toasts: &mut Toasts) { + // 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.add(toast); + } + } + } + + /// Handles loading the image in a background thread or on the main thread. + /// 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() { + 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()) + } + ); + + let mut image = image.clone(); + + 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 loading_logic = move || { + let mut optimizations = Vec::new(); + + loading_msg!("Applying image optimizations...", image_loading_arc); + optimizations = apply_image_optimizations(optimizations, &image.image_size); + + loading_msg!("Loading image...", image_loading_arc); + let result = image.load_image(&optimizations); + + if let Err(error) = result { + let mut toasts = toasts_queue_arc.lock().unwrap(); + + let mut toast = Toast::error(error.message()); + toast.duration(Some(Duration::from_secs(10))); + + toasts.push(toast); + + log::error!("{}", error.message()); + } + + let mut image_loaded = image_loaded_arc.lock().unwrap(); + let mut image_loading = image_loading_arc.lock().unwrap(); + + *image_loaded = true; + *image_loading = None; + }; + + if lazy_load { + debug!("Lazy loading image (in a thread)..."); + thread::spawn(loading_logic); + } else { + debug!("Loading image in main thread..."); + loading_logic() + } + } +} \ No newline at end of file diff --git a/src/info_box.rs b/src/info_box.rs index 607574e..c40fb42 100644 --- a/src/info_box.rs +++ b/src/info_box.rs @@ -11,7 +11,7 @@ static ALLOCATOR: Cap = Cap::new(alloc::System, usize::max_value( pub struct InfoBox { pub show: bool, - theme: Theme, + theme: Option, image: Option, pub response: Option } @@ -19,15 +19,20 @@ pub struct InfoBox { impl InfoBox { // TODO: When this branch is merged into main // remove "image" from the initialization of this struct. - pub fn new(image: Option, theme: Theme) -> Self { + pub fn new() -> Self { Self { show: false, - image: image, - theme: theme, + image: None, + theme: None, response: None } } + pub fn init(&mut self, image: &Option, theme: &Theme) { + self.image = image.clone(); + self.theme = Some(theme.clone()); + } + pub fn handle_input(&mut self, ctx: &egui::Context) { if ctx.input(|i| i.key_pressed(Key::I)) { if self.show == true { @@ -41,7 +46,13 @@ impl InfoBox { pub fn update(&mut self, ctx: &egui::Context) { if self.show { let mut custom_frame = egui::Frame::window(&ctx.style()); - custom_frame.fill = Color32::from_hex(&self.theme.hex_code).unwrap().gamma_multiply(3.0); + + custom_frame.fill = Color32::from_hex( + &self.theme.as_ref().expect( + "InfoBox MUST be initialized before update can be called!" + ).hex_code + ).unwrap().gamma_multiply(3.0); + custom_frame.shadow = Shadow::NONE; let response = egui::Window::new( diff --git a/src/main.rs b/src/main.rs index 1c578fd..44a4e58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod image; mod error; mod info_box; mod zoom_pan; +mod image_loader; mod window_scaling; /// 🌹 A fast as fuck, memory efficient and simple but fancy image viewer built with 🦀 Rust that's cross platform. From bc17f4f9bb563d94db6d33075efbcb18a79876f5 Mon Sep 17 00:00:00 2001 From: Goldy <66202304+THEGOLDENPRO@users.noreply.github.com> Date: Tue, 12 Nov 2024 01:18:10 +0000 Subject: [PATCH 8/8] refactor, chore: move svg warning to image loader, update deps and version bump --- Cargo.toml | 8 ++++---- src/image.rs | 4 ++-- src/image_loader.rs | 21 +++++++++++++++++---- src/main.rs | 11 +---------- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 57abf2c..b9e36d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "roseate" edition = "2021" -version = "0.1.0-alpha.12" +version = "0.1.0-alpha.14" description = "A small and simple but fancy image viewer built with Rust that's cross platform." authors = ["Goldy "] license = "GPL-3.0" @@ -15,16 +15,16 @@ 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.4", features = ["jpeg", "png"]} +image = {version = "0.25.5", features = ["jpeg", "png"]} egui_animation = "0.6.0" simple-easing = "1.0.1" log = "0.4.22" env_logger = "0.11.5" -re_format = "0.19.0" +re_format = "0.19.1" cap = "0.1.2" clap = {version = "4.5.20", features = ["derive"]} rand = "0.8.5" -display-info = "0.5.1" +display-info = "0.5.2" egui-notify = "0.17.0" svg_metadata = "0.5.1" textwrap = "0.16.1" diff --git a/src/image.rs b/src/image.rs index e637b35..52442bd 100644 --- a/src/image.rs +++ b/src/image.rs @@ -103,8 +103,8 @@ impl Image { if let Err(image_error) = image_result { let result_of_second_load = self.load_image(&[]); // load image without optimizations - if let Err(error) = result_of_second_load { - return Err(error); + if result_of_second_load.is_err() { + return result_of_second_load; } return Err( diff --git a/src/image_loader.rs b/src/image_loader.rs index 576fac0..47b7e1f 100644 --- a/src/image_loader.rs +++ b/src/image_loader.rs @@ -84,6 +84,19 @@ impl ImageLoader { 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); + } + 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(); @@ -98,12 +111,12 @@ impl ImageLoader { let result = image.load_image(&optimizations); if let Err(error) = result { - let mut toasts = toasts_queue_arc.lock().unwrap(); - - let mut toast = Toast::error(error.message()); + let mut toast = Toast::error( + textwrap::wrap(&error.message(), 100).join("\n") + ); toast.duration(Some(Duration::from_secs(10))); - toasts.push(toast); + toasts_queue_arc.lock().unwrap().push(toast); log::error!("{}", error.message()); } diff --git a/src/main.rs b/src/main.rs index 44a4e58..2b6de56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::{env, path::Path, time::Duration}; use log::debug; use eframe::egui; -use egui_notify::{ToastLevel, Toasts}; +use egui_notify::Toasts; use cirrus_theming::Theme; use clap::{arg, command, Parser}; @@ -73,15 +73,6 @@ fn main() -> eframe::Result { None } else { - // Our svg implementation is very experimental. Let's warn the user. - if path.extension().unwrap_or_default() == "svg" { - log_and_toast( - "SVG files are experimental! \ - Expect many bugs, inconstancies and performance issues.".into(), - &mut toasts - ).level(ToastLevel::Warning).duration(Some(Duration::from_secs(8))); - } - Some(Image::from_path(path)) } },