diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index a878bd5fd70..21e54d2cdc3 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -673,6 +673,7 @@ impl WidgetInfo { WidgetType::DragValue => "drag value", WidgetType::ColorButton => "color button", WidgetType::ImageButton => "image button", + WidgetType::Image => "image", WidgetType::CollapsingHeader => "collapsing header", WidgetType::ProgressIndicator => "progress indicator", WidgetType::Window => "window", diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 1afaada95e1..954561a8cdb 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -665,6 +665,8 @@ pub enum WidgetType { ImageButton, + Image, + CollapsingHeader, ProgressIndicator, diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 18ddf793cc6..c65d9ca8c71 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -1017,6 +1017,7 @@ impl Response { WidgetType::Button | WidgetType::ImageButton | WidgetType::CollapsingHeader => { Role::Button } + WidgetType::Image => Role::Image, WidgetType::Checkbox => Role::CheckBox, WidgetType::RadioButton => Role::RadioButton, WidgetType::RadioGroup => Role::RadioGroup, diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index e4355b49e8d..088800e45ce 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -344,6 +344,7 @@ impl Widget for Button<'_> { image_rect, image.show_loading_spinner, &image_options, + None, ); response = widgets::image::texture_load_result_response( &image.source(ui.ctx()), diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 4cdfc5bf749..d08b6e1264e 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,12 +1,15 @@ use std::{borrow::Cow, sync::Arc, time::Duration}; -use emath::{Float as _, Rot2}; -use epaint::RectShape; +use emath::{Align, Float as _, Rot2}; +use epaint::{ + text::{LayoutJob, TextFormat, TextWrapping}, + RectShape, +}; use crate::{ load::{Bytes, SizeHint, SizedTexture, TextureLoadResult, TexturePoll}, - pos2, Align2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, - Spinner, Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, + pos2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, Spinner, + Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, WidgetInfo, WidgetType, }; /// A widget which displays an image. @@ -51,6 +54,7 @@ pub struct Image<'a> { sense: Sense, size: ImageSize, pub(crate) show_loading_spinner: Option, + alt_text: Option, } impl<'a> Image<'a> { @@ -76,6 +80,7 @@ impl<'a> Image<'a> { sense: Sense::hover(), size, show_loading_spinner: None, + alt_text: None, } } @@ -255,6 +260,14 @@ impl<'a> Image<'a> { self.show_loading_spinner = Some(show); self } + + /// Set alt text for the image. This will be shown when the image fails to load. + /// It will also be read to screen readers. + #[inline] + pub fn alt_text(mut self, label: impl Into) -> Self { + self.alt_text = Some(label.into()); + self + } } impl<'a, T: Into>> From for Image<'a> { @@ -352,6 +365,7 @@ impl<'a> Image<'a> { rect, self.show_loading_spinner, &self.image_options, + self.alt_text.as_deref(), ); } } @@ -363,6 +377,11 @@ impl<'a> Widget for Image<'a> { let ui_size = self.calc_size(ui.available_size(), original_image_size); let (rect, response) = ui.allocate_exact_size(ui_size, self.sense); + response.widget_info(|| { + let mut info = WidgetInfo::new(WidgetType::Image); + info.label = self.alt_text.clone(); + info + }); if ui.is_rect_visible(rect) { paint_texture_load_result( ui, @@ -370,6 +389,7 @@ impl<'a> Widget for Image<'a> { rect, self.show_loading_spinner, &self.image_options, + self.alt_text.as_deref(), ); } texture_load_result_response(&self.source(ui.ctx()), &tlr, response) @@ -600,6 +620,7 @@ pub fn paint_texture_load_result( rect: Rect, show_loading_spinner: Option, options: &ImageOptions, + alt: Option<&str>, ) { match tlr { Ok(TexturePoll::Ready { texture }) => { @@ -614,12 +635,28 @@ pub fn paint_texture_load_result( } Err(_) => { let font_id = TextStyle::Body.resolve(ui.style()); - ui.painter().text( - rect.center(), - Align2::CENTER_CENTER, + let mut job = LayoutJob { + wrap: TextWrapping::truncate_at_width(rect.width()), + halign: Align::Center, + ..Default::default() + }; + job.append( "⚠", - font_id, - ui.visuals().error_fg_color, + 0.0, + TextFormat::simple(font_id.clone(), ui.visuals().error_fg_color), + ); + if let Some(alt) = alt { + job.append( + alt, + ui.spacing().item_spacing.x, + TextFormat::simple(font_id, ui.visuals().text_color()), + ); + } + let galley = ui.painter().layout_job(job); + ui.painter().galley( + rect.center() - Vec2::Y * galley.size().y * 0.5, + galley, + ui.visuals().text_color(), ); } } diff --git a/crates/egui/src/widgets/image_button.rs b/crates/egui/src/widgets/image_button.rs index bcae9a991d5..fdcae898acb 100644 --- a/crates/egui/src/widgets/image_button.rs +++ b/crates/egui/src/widgets/image_button.rs @@ -11,6 +11,7 @@ pub struct ImageButton<'a> { sense: Sense, frame: bool, selected: bool, + alt_text: Option, } impl<'a> ImageButton<'a> { @@ -20,6 +21,7 @@ impl<'a> ImageButton<'a> { sense: Sense::click(), frame: true, selected: false, + alt_text: None, } } @@ -87,7 +89,11 @@ impl<'a> Widget for ImageButton<'a> { let padded_size = image_size + 2.0 * padding; let (rect, response) = ui.allocate_exact_size(padded_size, self.sense); - response.widget_info(|| WidgetInfo::new(WidgetType::ImageButton)); + response.widget_info(|| { + let mut info = WidgetInfo::new(WidgetType::ImageButton); + info.label = self.alt_text.clone(); + info + }); if ui.is_rect_visible(rect) { let (expansion, rounding, fill, stroke) = if self.selected { @@ -121,7 +127,14 @@ impl<'a> Widget for ImageButton<'a> { // let image_rect = image_rect.expand2(expansion); // can make it blurry, so let's not let image_options = self.image.image_options().clone(); - widgets::image::paint_texture_load_result(ui, &tlr, image_rect, None, &image_options); + widgets::image::paint_texture_load_result( + ui, + &tlr, + image_rect, + None, + &image_options, + self.alt_text.as_deref(), + ); // Draw frame outline: ui.painter() diff --git a/crates/egui_demo_app/src/apps/image_viewer.rs b/crates/egui_demo_app/src/apps/image_viewer.rs index ad7a8448640..80961915eb5 100644 --- a/crates/egui_demo_app/src/apps/image_viewer.rs +++ b/crates/egui_demo_app/src/apps/image_viewer.rs @@ -14,6 +14,7 @@ pub struct ImageViewer { fit: ImageFit, maintain_aspect_ratio: bool, max_size: Vec2, + alt_text: String, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -44,6 +45,7 @@ impl Default for ImageViewer { fit: ImageFit::Fraction(Vec2::splat(1.0)), maintain_aspect_ratio: true, max_size: Vec2::splat(2048.0), + alt_text: "My Image".to_owned(), } } } @@ -185,6 +187,11 @@ impl eframe::App for ImageViewer { ui.label("Aspect ratio is maintained by scaling both sides as necessary"); ui.checkbox(&mut self.maintain_aspect_ratio, "Maintain aspect ratio"); + // alt text + ui.add_space(5.0); + ui.label("Alt text"); + ui.text_edit_singleline(&mut self.alt_text); + // forget all images if ui.button("Forget all images").clicked() { ui.ctx().forget_all_images(); @@ -211,6 +218,9 @@ impl eframe::App for ImageViewer { } image = image.maintain_aspect_ratio(self.maintain_aspect_ratio); image = image.max_size(self.max_size); + if !self.alt_text.is_empty() { + image = image.alt_text(&self.alt_text); + } ui.add_sized(ui.available_size(), image); }); diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png index 51596247499..6f06e7727f6 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d122b1a995e691b5049c57d65c9f222a5f1639b1e4f6f96f91823444339693cc -size 160540 +oid sha256:b3dc1bf9a59007a6ad0fb66a345d6cf272bd8bdcd26b10dbf411c1280e62b6fc +size 158285 diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index 9493d5443f4..690ca86f6b6 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -1,4 +1,4 @@ -use egui::Button; +use egui::{Button, Image, Vec2, Widget}; use egui_kittest::{kittest::Queryable, Harness}; #[test] @@ -27,3 +27,19 @@ pub fn focus_should_skip_over_disabled_buttons() { let button_1 = harness.get_by_label("Button 1"); assert!(button_1.is_focused()); } + +#[test] +fn image_failed() { + let mut harness = Harness::new_ui(|ui| { + Image::new("file://invalid/path") + .alt_text("I have an alt text") + .max_size(Vec2::new(100.0, 100.0)) + .ui(ui); + }); + + harness.run(); + harness.fit_contents(); + + #[cfg(all(feature = "wgpu", feature = "snapshot"))] + harness.wgpu_snapshot("image_snapshots"); +} diff --git a/crates/egui_kittest/tests/snapshots/image_snapshots.png b/crates/egui_kittest/tests/snapshots/image_snapshots.png new file mode 100644 index 00000000000..c1b7d6cefc9 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/image_snapshots.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31faeb4e5f488b8bcee5e090accd326d7e43b264e81768ae7c1907e3b6d0f739 +size 2121