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>
This commit is contained in:
parent
86ea3f8a5c
commit
e32ca218e8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -666,6 +666,8 @@ pub enum WidgetType {
|
|||
|
||||
ImageButton,
|
||||
|
||||
Image,
|
||||
|
||||
CollapsingHeader,
|
||||
|
||||
ProgressIndicator,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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> {
|
||||
|
|
@ -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<String>) -> Self {
|
||||
self.alt_text = Some(label.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T: Into<ImageSource<'a>>> From<T> for Image<'a> {
|
||||
|
|
@ -354,6 +367,7 @@ impl<'a> Image<'a> {
|
|||
rect,
|
||||
self.show_loading_spinner,
|
||||
&self.image_options,
|
||||
self.alt_text.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -365,6 +379,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,
|
||||
|
|
@ -372,6 +391,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)
|
||||
|
|
@ -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 }) => {
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub struct ImageButton<'a> {
|
|||
sense: Sense,
|
||||
frame: bool,
|
||||
selected: bool,
|
||||
alt_text: Option<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31faeb4e5f488b8bcee5e090accd326d7e43b264e81768ae7c1907e3b6d0f739
|
||||
size 2121
|
||||
Loading…
Reference in New Issue