Skip to content

Commit

Permalink
Add WidgetType::Image and Image::alt_text (#5534)
Browse files Browse the repository at this point in the history
This adds `WidgetType::Image` and correctly sets it in the Image widget.
This allows us to query for images in kittest tests and tells accesskit
that a node is an image.
It also adds `Image::alt_text` to set a text that will be shown if the
image fails to load and will be read via screen readers. This also
allows us to query images by label in kittest.


* [x] I have followed the instructions in the PR template

---------

Co-authored-by: Emil Ernerfeldt <[email protected]>
  • Loading branch information
lucasmerlin and emilk authored Dec 30, 2024
1 parent 86ea3f8 commit e32ca21
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 12 deletions.
1 change: 1 addition & 0 deletions crates/egui/src/data/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,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",
Expand Down
2 changes: 2 additions & 0 deletions crates/egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,8 @@ pub enum WidgetType {

ImageButton,

Image,

CollapsingHeader,

ProgressIndicator,
Expand Down
1 change: 1 addition & 0 deletions crates/egui/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/egui/src/widgets/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
55 changes: 46 additions & 9 deletions crates/egui/src/widgets/image.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use std::{borrow::Cow, slice::Iter, 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.
Expand Down Expand Up @@ -51,6 +54,7 @@ pub struct Image<'a> {
sense: Sense,
size: ImageSize,
pub(crate) show_loading_spinner: Option<bool>,
alt_text: Option<String>,
}

impl<'a> Image<'a> {
Expand All @@ -76,6 +80,7 @@ impl<'a> Image<'a> {
sense: Sense::hover(),
size,
show_loading_spinner: None,
alt_text: None,
}
}

Expand Down Expand Up @@ -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<String>) -> Self {
self.alt_text = Some(label.into());
self
}
}

impl<'a, T: Into<ImageSource<'a>>> From<T> for Image<'a> {
Expand Down Expand Up @@ -354,6 +367,7 @@ impl<'a> Image<'a> {
rect,
self.show_loading_spinner,
&self.image_options,
self.alt_text.as_deref(),
);
}
}
Expand All @@ -365,13 +379,19 @@ 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,
&tlr,
rect,
self.show_loading_spinner,
&self.image_options,
self.alt_text.as_deref(),
);
}
texture_load_result_response(&self.source(ui.ctx()), &tlr, response)
Expand Down Expand Up @@ -602,6 +622,7 @@ pub fn paint_texture_load_result(
rect: Rect,
show_loading_spinner: Option<bool>,
options: &ImageOptions,
alt: Option<&str>,
) {
match tlr {
Ok(TexturePoll::Ready { texture }) => {
Expand All @@ -616,12 +637,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(),
);
}
}
Expand Down
17 changes: 15 additions & 2 deletions crates/egui/src/widgets/image_button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub struct ImageButton<'a> {
sense: Sense,
frame: bool,
selected: bool,
alt_text: Option<String>,
}

impl<'a> ImageButton<'a> {
Expand All @@ -20,6 +21,7 @@ impl<'a> ImageButton<'a> {
sense: Sense::click(),
frame: true,
selected: false,
alt_text: None,
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions crates/egui_demo_app/src/apps/image_viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub struct ImageViewer {
fit: ImageFit,
maintain_aspect_ratio: bool,
max_size: Vec2,
alt_text: String,
}

#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
});
Expand Down
18 changes: 17 additions & 1 deletion crates/egui_kittest/tests/regression_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use egui::Button;
use egui::{Button, Image, Vec2, Widget};
use egui_kittest::{kittest::Queryable, Harness};

#[test]
Expand Down Expand Up @@ -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");
}
3 changes: 3 additions & 0 deletions crates/egui_kittest/tests/snapshots/image_snapshots.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit e32ca21

Please sign in to comment.