Skip to content

Commit e32ca21

Browse files
lucasmerlinemilk
andauthored
Add WidgetType::Image and Image::alt_text (#5534)
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 <emil.ernerfeldt@gmail.com>
1 parent 86ea3f8 commit e32ca21

File tree

9 files changed

+96
-12
lines changed

9 files changed

+96
-12
lines changed

crates/egui/src/data/output.rs

+1
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ impl WidgetInfo {
700700
WidgetType::DragValue => "drag value",
701701
WidgetType::ColorButton => "color button",
702702
WidgetType::ImageButton => "image button",
703+
WidgetType::Image => "image",
703704
WidgetType::CollapsingHeader => "collapsing header",
704705
WidgetType::ProgressIndicator => "progress indicator",
705706
WidgetType::Window => "window",

crates/egui/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,8 @@ pub enum WidgetType {
666666

667667
ImageButton,
668668

669+
Image,
670+
669671
CollapsingHeader,
670672

671673
ProgressIndicator,

crates/egui/src/response.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ impl Response {
10171017
WidgetType::Button | WidgetType::ImageButton | WidgetType::CollapsingHeader => {
10181018
Role::Button
10191019
}
1020+
WidgetType::Image => Role::Image,
10201021
WidgetType::Checkbox => Role::CheckBox,
10211022
WidgetType::RadioButton => Role::RadioButton,
10221023
WidgetType::RadioGroup => Role::RadioGroup,

crates/egui/src/widgets/button.rs

+1
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ impl Widget for Button<'_> {
344344
image_rect,
345345
image.show_loading_spinner,
346346
&image_options,
347+
None,
347348
);
348349
response = widgets::image::texture_load_result_response(
349350
&image.source(ui.ctx()),

crates/egui/src/widgets/image.rs

+46-9
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
use std::{borrow::Cow, slice::Iter, sync::Arc, time::Duration};
22

3-
use emath::{Float as _, Rot2};
4-
use epaint::RectShape;
3+
use emath::{Align, Float as _, Rot2};
4+
use epaint::{
5+
text::{LayoutJob, TextFormat, TextWrapping},
6+
RectShape,
7+
};
58

69
use crate::{
710
load::{Bytes, SizeHint, SizedTexture, TextureLoadResult, TexturePoll},
8-
pos2, Align2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape,
9-
Spinner, Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget,
11+
pos2, Color32, Context, Id, Mesh, Painter, Rect, Response, Rounding, Sense, Shape, Spinner,
12+
Stroke, TextStyle, TextureOptions, Ui, Vec2, Widget, WidgetInfo, WidgetType,
1013
};
1114

1215
/// A widget which displays an image.
@@ -51,6 +54,7 @@ pub struct Image<'a> {
5154
sense: Sense,
5255
size: ImageSize,
5356
pub(crate) show_loading_spinner: Option<bool>,
57+
alt_text: Option<String>,
5458
}
5559

5660
impl<'a> Image<'a> {
@@ -76,6 +80,7 @@ impl<'a> Image<'a> {
7680
sense: Sense::hover(),
7781
size,
7882
show_loading_spinner: None,
83+
alt_text: None,
7984
}
8085
}
8186

@@ -255,6 +260,14 @@ impl<'a> Image<'a> {
255260
self.show_loading_spinner = Some(show);
256261
self
257262
}
263+
264+
/// Set alt text for the image. This will be shown when the image fails to load.
265+
/// It will also be read to screen readers.
266+
#[inline]
267+
pub fn alt_text(mut self, label: impl Into<String>) -> Self {
268+
self.alt_text = Some(label.into());
269+
self
270+
}
258271
}
259272

260273
impl<'a, T: Into<ImageSource<'a>>> From<T> for Image<'a> {
@@ -354,6 +367,7 @@ impl<'a> Image<'a> {
354367
rect,
355368
self.show_loading_spinner,
356369
&self.image_options,
370+
self.alt_text.as_deref(),
357371
);
358372
}
359373
}
@@ -365,13 +379,19 @@ impl<'a> Widget for Image<'a> {
365379
let ui_size = self.calc_size(ui.available_size(), original_image_size);
366380

367381
let (rect, response) = ui.allocate_exact_size(ui_size, self.sense);
382+
response.widget_info(|| {
383+
let mut info = WidgetInfo::new(WidgetType::Image);
384+
info.label = self.alt_text.clone();
385+
info
386+
});
368387
if ui.is_rect_visible(rect) {
369388
paint_texture_load_result(
370389
ui,
371390
&tlr,
372391
rect,
373392
self.show_loading_spinner,
374393
&self.image_options,
394+
self.alt_text.as_deref(),
375395
);
376396
}
377397
texture_load_result_response(&self.source(ui.ctx()), &tlr, response)
@@ -602,6 +622,7 @@ pub fn paint_texture_load_result(
602622
rect: Rect,
603623
show_loading_spinner: Option<bool>,
604624
options: &ImageOptions,
625+
alt: Option<&str>,
605626
) {
606627
match tlr {
607628
Ok(TexturePoll::Ready { texture }) => {
@@ -616,12 +637,28 @@ pub fn paint_texture_load_result(
616637
}
617638
Err(_) => {
618639
let font_id = TextStyle::Body.resolve(ui.style());
619-
ui.painter().text(
620-
rect.center(),
621-
Align2::CENTER_CENTER,
640+
let mut job = LayoutJob {
641+
wrap: TextWrapping::truncate_at_width(rect.width()),
642+
halign: Align::Center,
643+
..Default::default()
644+
};
645+
job.append(
622646
"⚠",
623-
font_id,
624-
ui.visuals().error_fg_color,
647+
0.0,
648+
TextFormat::simple(font_id.clone(), ui.visuals().error_fg_color),
649+
);
650+
if let Some(alt) = alt {
651+
job.append(
652+
alt,
653+
ui.spacing().item_spacing.x,
654+
TextFormat::simple(font_id, ui.visuals().text_color()),
655+
);
656+
}
657+
let galley = ui.painter().layout_job(job);
658+
ui.painter().galley(
659+
rect.center() - Vec2::Y * galley.size().y * 0.5,
660+
galley,
661+
ui.visuals().text_color(),
625662
);
626663
}
627664
}

crates/egui/src/widgets/image_button.rs

+15-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct ImageButton<'a> {
1111
sense: Sense,
1212
frame: bool,
1313
selected: bool,
14+
alt_text: Option<String>,
1415
}
1516

1617
impl<'a> ImageButton<'a> {
@@ -20,6 +21,7 @@ impl<'a> ImageButton<'a> {
2021
sense: Sense::click(),
2122
frame: true,
2223
selected: false,
24+
alt_text: None,
2325
}
2426
}
2527

@@ -87,7 +89,11 @@ impl<'a> Widget for ImageButton<'a> {
8789

8890
let padded_size = image_size + 2.0 * padding;
8991
let (rect, response) = ui.allocate_exact_size(padded_size, self.sense);
90-
response.widget_info(|| WidgetInfo::new(WidgetType::ImageButton));
92+
response.widget_info(|| {
93+
let mut info = WidgetInfo::new(WidgetType::ImageButton);
94+
info.label = self.alt_text.clone();
95+
info
96+
});
9197

9298
if ui.is_rect_visible(rect) {
9399
let (expansion, rounding, fill, stroke) = if self.selected {
@@ -121,7 +127,14 @@ impl<'a> Widget for ImageButton<'a> {
121127
// let image_rect = image_rect.expand2(expansion); // can make it blurry, so let's not
122128
let image_options = self.image.image_options().clone();
123129

124-
widgets::image::paint_texture_load_result(ui, &tlr, image_rect, None, &image_options);
130+
widgets::image::paint_texture_load_result(
131+
ui,
132+
&tlr,
133+
image_rect,
134+
None,
135+
&image_options,
136+
self.alt_text.as_deref(),
137+
);
125138

126139
// Draw frame outline:
127140
ui.painter()

crates/egui_demo_app/src/apps/image_viewer.rs

+10
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub struct ImageViewer {
1414
fit: ImageFit,
1515
maintain_aspect_ratio: bool,
1616
max_size: Vec2,
17+
alt_text: String,
1718
}
1819

1920
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -44,6 +45,7 @@ impl Default for ImageViewer {
4445
fit: ImageFit::Fraction(Vec2::splat(1.0)),
4546
maintain_aspect_ratio: true,
4647
max_size: Vec2::splat(2048.0),
48+
alt_text: "My Image".to_owned(),
4749
}
4850
}
4951
}
@@ -185,6 +187,11 @@ impl eframe::App for ImageViewer {
185187
ui.label("Aspect ratio is maintained by scaling both sides as necessary");
186188
ui.checkbox(&mut self.maintain_aspect_ratio, "Maintain aspect ratio");
187189

190+
// alt text
191+
ui.add_space(5.0);
192+
ui.label("Alt text");
193+
ui.text_edit_singleline(&mut self.alt_text);
194+
188195
// forget all images
189196
if ui.button("Forget all images").clicked() {
190197
ui.ctx().forget_all_images();
@@ -211,6 +218,9 @@ impl eframe::App for ImageViewer {
211218
}
212219
image = image.maintain_aspect_ratio(self.maintain_aspect_ratio);
213220
image = image.max_size(self.max_size);
221+
if !self.alt_text.is_empty() {
222+
image = image.alt_text(&self.alt_text);
223+
}
214224

215225
ui.add_sized(ui.available_size(), image);
216226
});

crates/egui_kittest/tests/regression_tests.rs

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use egui::Button;
1+
use egui::{Button, Image, Vec2, Widget};
22
use egui_kittest::{kittest::Queryable, Harness};
33

44
#[test]
@@ -27,3 +27,19 @@ pub fn focus_should_skip_over_disabled_buttons() {
2727
let button_1 = harness.get_by_label("Button 1");
2828
assert!(button_1.is_focused());
2929
}
30+
31+
#[test]
32+
fn image_failed() {
33+
let mut harness = Harness::new_ui(|ui| {
34+
Image::new("file://invalid/path")
35+
.alt_text("I have an alt text")
36+
.max_size(Vec2::new(100.0, 100.0))
37+
.ui(ui);
38+
});
39+
40+
harness.run();
41+
harness.fit_contents();
42+
43+
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
44+
harness.wgpu_snapshot("image_snapshots");
45+
}
Loading

0 commit comments

Comments
 (0)