From 2ba73a01d49464c695fe90b389bbaed4a7630a2a Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 28 Aug 2023 18:56:41 +0200 Subject: [PATCH 1/5] Move image loading to its own file --- src/image_loading.rs | 45 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 46 ++++---------------------------------------- 2 files changed, 49 insertions(+), 42 deletions(-) create mode 100644 src/image_loading.rs diff --git a/src/image_loading.rs b/src/image_loading.rs new file mode 100644 index 0000000..6f5dbe3 --- /dev/null +++ b/src/image_loading.rs @@ -0,0 +1,45 @@ +use egui::ColorImage; + +pub fn load_image(data: &[u8]) -> Option { + try_load_image(data).ok().or_else(|| try_render_svg(data)) +} + +fn try_load_image(data: &[u8]) -> image::ImageResult { + let image = image::load_from_memory(data)?; + let image_buffer = image.to_rgba8(); + let size = [image.width() as usize, image.height() as usize]; + let pixels = image_buffer.as_flat_samples(); + + Ok(ColorImage::from_rgba_unmultiplied(size, pixels.as_slice())) +} + +#[cfg(not(feature = "svg"))] +fn try_render_svg(_data: &[u8]) -> Option { + None +} + +#[cfg(feature = "svg")] +fn try_render_svg(data: &[u8]) -> Option { + use resvg::tiny_skia; + use usvg::{TreeParsing, TreeTextToPath}; + + let tree = { + let options = usvg::Options::default(); + let mut fontdb = usvg::fontdb::Database::new(); + fontdb.load_system_fonts(); + + let mut tree = usvg::Tree::from_data(data, &options).ok()?; + tree.convert_text(&fontdb); + resvg::Tree::from_usvg(&tree) + }; + + let size = tree.size.to_int_size(); + + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; + tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut()); + + Some(ColorImage::from_rgba_unmultiplied( + [pixmap.width() as usize, pixmap.height() as usize], + &pixmap.take(), + )) +} diff --git a/src/lib.rs b/src/lib.rs index 9e3bde7..8292cdb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,8 +19,10 @@ //! //! ``` +mod image_loading; + +use egui::TextureHandle; use egui::{self, epaint, Id, NumExt, Pos2, RichText, Sense, TextStyle, Ui, Vec2}; -use egui::{ColorImage, TextureHandle}; use pulldown_cmark::{CowStr, HeadingLevel, Options}; use std::collections::hash_map::Entry; use std::collections::HashMap; @@ -34,46 +36,6 @@ use syntect::{ util::LinesWithEndings, }; -fn load_image(data: &[u8]) -> image::ImageResult { - let image = image::load_from_memory(data)?; - let image_buffer = image.to_rgba8(); - let size = [image.width() as usize, image.height() as usize]; - let pixels = image_buffer.as_flat_samples(); - - Ok(ColorImage::from_rgba_unmultiplied(size, pixels.as_slice())) -} - -#[cfg(not(feature = "svg"))] -fn try_render_svg(_data: &[u8]) -> Option { - None -} - -#[cfg(feature = "svg")] -fn try_render_svg(data: &[u8]) -> Option { - use resvg::tiny_skia; - use usvg::{TreeParsing, TreeTextToPath}; - - let tree = { - let options = usvg::Options::default(); - let mut fontdb = usvg::fontdb::Database::new(); - fontdb.load_system_fonts(); - - let mut tree = usvg::Tree::from_data(data, &options).ok()?; - tree.convert_text(&fontdb); - resvg::Tree::from_usvg(&tree) - }; - - let size = tree.size.to_int_size(); - - let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; - tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut()); - - Some(ColorImage::from_rgba_unmultiplied( - [pixmap.width() as usize, pixmap.height() as usize], - &pixmap.take(), - )) -} - #[derive(Default, Debug)] struct ScrollableCache { available_size: Vec2, @@ -1238,7 +1200,7 @@ fn width_body_space(ui: &Ui) -> f32 { } fn parse_image(ctx: &egui::Context, url: &str, data: &[u8]) -> Option { - let image = load_image(data).ok().or_else(|| try_render_svg(data)); + let image = image_loading::load_image(data); image.map(|image| ctx.load_texture(url, image, egui::TextureOptions::LINEAR)) } From 363cfbf639c6cf383afdf82587af2d7432ed815e Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 28 Aug 2023 18:59:15 +0200 Subject: [PATCH 2/5] Move image fetching to own file --- src/fetch_data.rs | 33 +++++++++++++++++++++++++++++++++ src/lib.rs | 37 +++---------------------------------- 2 files changed, 36 insertions(+), 34 deletions(-) create mode 100644 src/fetch_data.rs diff --git a/src/fetch_data.rs b/src/fetch_data.rs new file mode 100644 index 0000000..858e327 --- /dev/null +++ b/src/fetch_data.rs @@ -0,0 +1,33 @@ +use crate::ImageHashMap; + +#[cfg(not(feature = "fetch"))] +pub fn get_image_data(path: &str, _ctx: &egui::Context, _images: ImageHashMap) -> Option> { + get_image_data_from_file(path) +} + +#[cfg(feature = "fetch")] +fn get_image_data(path: &str, ctx: &egui::Context, images: ImageHashMap) -> Option> { + let url = url::Url::parse(path); + if url.is_ok() { + let ctx2 = ctx.clone(); + let path = path.to_owned(); + ehttp::fetch(ehttp::Request::get(&path), move |r| { + if let Ok(r) = r { + let data = r.bytes; + if let Some(handle) = parse_image(&ctx2, &path, &data) { + // we only update if the image was loaded properly + *images.lock().unwrap().get_mut(&path).unwrap() = Some(handle); + ctx2.request_repaint(); + } + } + }); + + None + } else { + get_image_data_from_file(path) + } +} + +fn get_image_data_from_file(url: &str) -> Option> { + std::fs::read(url).ok() +} diff --git a/src/lib.rs b/src/lib.rs index 8292cdb..c6761ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ //! //! ``` +mod fetch_data; mod image_loading; use egui::TextureHandle; @@ -43,7 +44,7 @@ struct ScrollableCache { split_points: Vec<(usize, Pos2, Pos2)>, } -type ImageHashMap = Arc>>>; +pub(crate) type ImageHashMap = Arc>>>; /// A cache used for storing content such as images. pub struct CommonMarkCache { @@ -951,7 +952,7 @@ impl CommonMarkViewerInternal { Entry::Occupied(o) => o.get().clone(), Entry::Vacant(v) => { let ctx = ui.ctx(); - let handle = get_image_data(&url, ctx, Arc::clone(&cache.images)) + let handle = fetch_data::get_image_data(&url, ctx, Arc::clone(&cache.images)) .and_then(|data| parse_image(ctx, &url, &data)); v.insert(handle.clone()); @@ -1204,38 +1205,6 @@ fn parse_image(ctx: &egui::Context, url: &str, data: &[u8]) -> Option Option> { - let url = url::Url::parse(path); - if url.is_ok() { - let ctx2 = ctx.clone(); - let path = path.to_owned(); - ehttp::fetch(ehttp::Request::get(&path), move |r| { - if let Ok(r) = r { - let data = r.bytes; - if let Some(handle) = parse_image(&ctx2, &path, &data) { - // we only update if the image was loaded properly - *images.lock().unwrap().get_mut(&path).unwrap() = Some(handle); - ctx2.request_repaint(); - } - } - }); - - None - } else { - get_image_data_from_file(path) - } -} - -#[cfg(not(feature = "fetch"))] -fn get_image_data(path: &str, _ctx: &egui::Context, _images: ImageHashMap) -> Option> { - get_image_data_from_file(path) -} - -fn get_image_data_from_file(url: &str) -> Option> { - std::fs::read(url).ok() -} - #[cfg(feature = "syntax_highlighting")] fn default_theme(ui: &Ui) -> &str { if ui.style().visuals.dark_mode { From ba565706175a5e4c055d32b1c03787630cdd7db6 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 28 Aug 2023 20:51:54 +0200 Subject: [PATCH 3/5] Show image loading errors to the user --- Cargo.toml | 8 ++- src/fetch_data.rs | 38 +++++++------- src/image_loading.rs | 22 +++++--- src/lib.rs | 117 +++++++++++++++++++++++++++++-------------- 4 files changed, 117 insertions(+), 68 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8a61760..250219f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,14 @@ include = ["src/*.rs", "LICENSE-MIT", "LICENSE-APACHE", "Cargo.toml"] [dependencies] egui = { git = "https://github.com/emilk/egui", rev = "2c7c598" } -pulldown-cmark = { version = "0.9.3", default-features = false } image = { version = "0.24", default-features = false, features = ["png"] } +parking_lot = "0.12" +poll-promise = "0.3" +pulldown-cmark = { version = "0.9.3", default-features = false } -syntect = { version = "5.0.0", optional = true, default-features = false, features = ["default-fancy"] } +syntect = { version = "5.0.0", optional = true, default-features = false, features = [ + "default-fancy", +] } resvg = { version = "0.35.0", optional = true } usvg = { version = "0.35.0", optional = true } diff --git a/src/fetch_data.rs b/src/fetch_data.rs index 858e327..76bc4d4 100644 --- a/src/fetch_data.rs +++ b/src/fetch_data.rs @@ -1,33 +1,29 @@ -use crate::ImageHashMap; - #[cfg(not(feature = "fetch"))] -pub fn get_image_data(path: &str, _ctx: &egui::Context, _images: ImageHashMap) -> Option> { - get_image_data_from_file(path) +pub fn get_image_data(uri: &str, on_done: impl 'static + Send + FnOnce(Result, String>)) { + get_image_data_from_file(uri, on_done) } #[cfg(feature = "fetch")] -fn get_image_data(path: &str, ctx: &egui::Context, images: ImageHashMap) -> Option> { - let url = url::Url::parse(path); +pub fn get_image_data(uri: &str, on_done: impl 'static + Send + FnOnce(Result, String>)) { + let url = url::Url::parse(uri); if url.is_ok() { - let ctx2 = ctx.clone(); - let path = path.to_owned(); - ehttp::fetch(ehttp::Request::get(&path), move |r| { - if let Ok(r) = r { - let data = r.bytes; - if let Some(handle) = parse_image(&ctx2, &path, &data) { - // we only update if the image was loaded properly - *images.lock().unwrap().get_mut(&path).unwrap() = Some(handle); - ctx2.request_repaint(); - } + let uri = uri.to_owned(); + ehttp::fetch(ehttp::Request::get(&uri), move |result| match result { + Ok(response) => { + on_done(Ok(response.bytes)); + } + Err(err) => { + on_done(Err(err)); } }); - - None } else { - get_image_data_from_file(path) + get_image_data_from_file(uri, on_done) } } -fn get_image_data_from_file(url: &str) -> Option> { - std::fs::read(url).ok() +fn get_image_data_from_file( + path: &str, + on_done: impl 'static + Send + FnOnce(Result, String>), +) { + on_done(std::fs::read(path).map_err(|err| err.to_string())); } diff --git a/src/image_loading.rs b/src/image_loading.rs index 6f5dbe3..a24df9c 100644 --- a/src/image_loading.rs +++ b/src/image_loading.rs @@ -1,7 +1,11 @@ use egui::ColorImage; -pub fn load_image(data: &[u8]) -> Option { - try_load_image(data).ok().or_else(|| try_render_svg(data)) +pub fn load_image(url: &str, data: &[u8]) -> Result { + if url.ends_with(".svg") { + try_render_svg(data) + } else { + try_load_image(data).map_err(|err| err.to_string()) + } } fn try_load_image(data: &[u8]) -> image::ImageResult { @@ -14,12 +18,12 @@ fn try_load_image(data: &[u8]) -> image::ImageResult { } #[cfg(not(feature = "svg"))] -fn try_render_svg(_data: &[u8]) -> Option { - None +fn try_render_svg(_data: &[u8]) -> Result { + Err("SVG support not enabled".to_owned()) } #[cfg(feature = "svg")] -fn try_render_svg(data: &[u8]) -> Option { +fn try_render_svg(data: &[u8]) -> Result { use resvg::tiny_skia; use usvg::{TreeParsing, TreeTextToPath}; @@ -28,17 +32,19 @@ fn try_render_svg(data: &[u8]) -> Option { let mut fontdb = usvg::fontdb::Database::new(); fontdb.load_system_fonts(); - let mut tree = usvg::Tree::from_data(data, &options).ok()?; + let mut tree = usvg::Tree::from_data(data, &options).map_err(|err| err.to_string())?; tree.convert_text(&fontdb); resvg::Tree::from_usvg(&tree) }; let size = tree.size.to_int_size(); - let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height())?; + let (w, h) = (size.width(), size.height()); + let mut pixmap = tiny_skia::Pixmap::new(w, h) + .ok_or_else(|| format!("Failed to create {w}x{h} SVG image"))?; tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut()); - Some(ColorImage::from_rgba_unmultiplied( + Ok(ColorImage::from_rgba_unmultiplied( [pixmap.width() as usize, pixmap.height() as usize], &pixmap.take(), )) diff --git a/src/lib.rs b/src/lib.rs index c6761ac..d3d90f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,12 +22,14 @@ mod fetch_data; mod image_loading; +use std::sync::Arc; +use std::{collections::HashMap, task::Poll}; + use egui::TextureHandle; use egui::{self, epaint, Id, NumExt, Pos2, RichText, Sense, TextStyle, Ui, Vec2}; +use parking_lot::Mutex; +use poll_promise::Promise; use pulldown_cmark::{CowStr, HeadingLevel, Options}; -use std::collections::hash_map::Entry; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; #[cfg(feature = "syntax_highlighting")] use syntect::{ @@ -44,18 +46,62 @@ struct ScrollableCache { split_points: Vec<(usize, Pos2, Pos2)>, } -pub(crate) type ImageHashMap = Arc>>>; +#[derive(Default)] +struct ImaheHandleCache { + cache: HashMap>>, +} + +impl ImaheHandleCache { + fn clear(&mut self) { + self.cache.clear(); + } + + fn load(&mut self, ctx: &egui::Context, url: String) -> Poll> { + let promise = self.cache.entry(url.clone()).or_insert_with(|| { + let ctx = ctx.clone(); + let (sender, promise) = Promise::new(); + fetch_data::get_image_data(&url.clone(), move |result| { + match result { + Ok(bytes) => { + sender.send(parse_image(&ctx, &url, &bytes)); + } + + Err(err) => { + sender.send(Err(err)); + } + }; + ctx.request_repaint(); + }); + promise + }); + + promise.poll().map(|r| r.clone()) + } + + fn loaded(&self) -> impl Iterator { + self.cache + .values() + .flat_map(|p| p.ready()) + .flat_map(|r| r.as_ref().ok()) + } +} + +impl ImaheHandleCache {} /// A cache used for storing content such as images. pub struct CommonMarkCache { // Everything stored here must take into account that the cache is for multiple // CommonMarkviewers with different source_ids. - images: ImageHashMap, + images: Arc>, + #[cfg(feature = "syntax_highlighting")] ps: SyntaxSet, + #[cfg(feature = "syntax_highlighting")] ts: ThemeSet, + link_hooks: HashMap, + scroll: HashMap, } @@ -142,7 +188,7 @@ impl CommonMarkCache { /// Refetch all images pub fn reload_images(&mut self) { - self.images.lock().unwrap().clear(); + self.images.lock().clear(); } /// Clear the cache for all scrollable elements @@ -208,7 +254,7 @@ impl CommonMarkCache { fn max_image_width(&self, options: &CommonMarkOptions) -> f32 { let mut max = 0.0; - for i in self.images.lock().unwrap().values().flatten() { + for i in self.images.lock().loaded() { let width = options.image_scaled(i)[0]; if width >= max { max = width; @@ -404,7 +450,7 @@ struct Link { } struct Image { - handle: Option, + handle: Poll>, url: String, alt_text: Vec, } @@ -948,17 +994,7 @@ impl CommonMarkViewerInternal { } fn start_image(&mut self, url: String, ui: &mut Ui, cache: &mut CommonMarkCache) { - let handle = match cache.images.lock().unwrap().entry(url.clone()) { - Entry::Occupied(o) => o.get().clone(), - Entry::Vacant(v) => { - let ctx = ui.ctx(); - let handle = fetch_data::get_image_data(&url, ctx, Arc::clone(&cache.images)) - .and_then(|data| parse_image(ctx, &url, &data)); - - v.insert(handle.clone()); - handle - } - }; + let handle = cache.images.lock().load(ui.ctx(), url.clone()); self.image = Some(Image { handle, @@ -969,23 +1005,30 @@ impl CommonMarkViewerInternal { fn end_image(&mut self, ui: &mut Ui, options: &CommonMarkOptions) { if let Some(image) = self.image.take() { - if let Some(texture) = image.handle { - let size = options.image_scaled(&texture); - let response = ui.image(&texture, size); - - if !image.alt_text.is_empty() && options.show_alt_text_on_hover { - response.on_hover_ui_at_pointer(|ui| { - for alt in image.alt_text { - ui.label(alt); - } - }); + let url = &image.url; + match image.handle { + Poll::Ready(Ok(texture)) => { + let size = options.image_scaled(&texture); + let response = ui.image(&texture, size); + + if !image.alt_text.is_empty() && options.show_alt_text_on_hover { + response.on_hover_ui_at_pointer(|ui| { + for alt in image.alt_text { + ui.label(alt); + } + }); + } } - } else { - ui.label("!["); - for alt in image.alt_text { - ui.label(alt); + Poll::Ready(Err(err)) => { + ui.colored_label( + ui.visuals().error_fg_color, + format!("Error loading {url}: {err}"), + ); + } + Poll::Pending => { + ui.spinner(); + ui.label(format!("Loading {url}…")); } - ui.label(format!("]({})", image.url)); } if self.should_insert_newline { @@ -1200,9 +1243,9 @@ fn width_body_space(ui: &Ui) -> f32 { ui.fonts(|f| f.glyph_width(&id, ' ')) } -fn parse_image(ctx: &egui::Context, url: &str, data: &[u8]) -> Option { - let image = image_loading::load_image(data); - image.map(|image| ctx.load_texture(url, image, egui::TextureOptions::LINEAR)) +fn parse_image(ctx: &egui::Context, url: &str, data: &[u8]) -> Result { + let image = image_loading::load_image(url, data)?; + Ok(ctx.load_texture(url, image, egui::TextureOptions::LINEAR)) } #[cfg(feature = "syntax_highlighting")] From 10d805288facb764850064bb1680114aa04e4a61 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 28 Aug 2023 23:04:03 +0200 Subject: [PATCH 4/5] Fix typo --- src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d3d90f7..0be3e79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,11 +47,11 @@ struct ScrollableCache { } #[derive(Default)] -struct ImaheHandleCache { +struct ImageHandleCache { cache: HashMap>>, } -impl ImaheHandleCache { +impl ImageHandleCache { fn clear(&mut self) { self.cache.clear(); } @@ -86,13 +86,13 @@ impl ImaheHandleCache { } } -impl ImaheHandleCache {} +impl ImageHandleCache {} /// A cache used for storing content such as images. pub struct CommonMarkCache { // Everything stored here must take into account that the cache is for multiple // CommonMarkviewers with different source_ids. - images: Arc>, + images: Arc>, #[cfg(feature = "syntax_highlighting")] ps: SyntaxSet, From 35ca1ed41ced2a3c0a6521188438c1c7dc15c436 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 28 Aug 2023 23:04:33 +0200 Subject: [PATCH 5/5] Fix a couple more typos --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0be3e79..0a1ad7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -203,7 +203,7 @@ impl CommonMarkCache { } /// If the user clicks on a link in the markdown render that has `name` as a link. The hook - /// specified with this method will be set to true. It's status can be aquired + /// specified with this method will be set to true. It's status can be acquired /// with [`get_link_hook`](Self::get_link_hook). Be aware that all hooks are reset once /// [`CommonMarkViewer::show`] gets called pub fn add_link_hook>(&mut self, name: S) { @@ -491,7 +491,7 @@ impl CommonMarkViewerInternal { } impl CommonMarkViewerInternal { - /// Be aware that this aquires egui::Context internally. + /// Be aware that this acquires egui::Context internally. pub fn show( &mut self, ui: &mut egui::Ui,