Group AccessKit nodes by `Ui` (#7386)
* closes https://github.com/emilk/egui/issues/5674 This changes egui to create an AccessKit node for each `Ui`. I'm not sure if this alone will directly improve accessibility, but it should make it easier to create the correct parent / child relations (e.g. grouping menus as children of menu buttons). Instead of having a global stack of parent ids, they are now passed via a parent_id field in `UiBuilder`. If having all these `GenericContainer` nodes somehow is bad for accessibility, the PR could also be changed to only create nodes if there is actually some accessibility info with it (the relevant is currently commented-out in the PR). But I think screen readers should just ignore these nodes, so it should be fine? We could also use this as motivation to git red of some unnecessary wrapped `Ui`s, e.g. CentralPanel creates 3 Uis when 2 should be enough (the initial Ui and a Frame, maybe we could even only show the `Frame` if we can give it an UiBuilder and somehow show the Frame with `Ui::new`). Here is a screenshot from the accessibility inspector (https://github.com/emilk/egui/pull/7368) with this PR: <img width="431" height="744" alt="Screenshot 2025-07-24 at 12 09 55" src="https://github.com/user-attachments/assets/6c4e5ff6-5c38-450e-9500-0776c9018d8c" /> Without this PR: https://github.com/user-attachments/assets/270e32fc-9c7a-4dad-8c90-7638c487a602
This commit is contained in:
parent
6a49c9ad6b
commit
3fdc5641aa
|
|
@ -595,6 +595,7 @@ impl Prepared {
|
|||
.layer_id(self.layer_id)
|
||||
.max_rect(max_rect)
|
||||
.layout(self.layout)
|
||||
.accessibility_parent(self.move_response.id)
|
||||
.closable();
|
||||
|
||||
if !self.enabled {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ use emath::GuiRounding as _;
|
|||
|
||||
use crate::{
|
||||
Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, NumExt as _, Rangef,
|
||||
Rect, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, lerp, vec2,
|
||||
Rect, Sense, Stroke, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetInfo, WidgetType, lerp,
|
||||
vec2,
|
||||
};
|
||||
|
||||
fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 {
|
||||
|
|
@ -390,6 +391,9 @@ impl SidePanel {
|
|||
.max_rect(available_rect),
|
||||
);
|
||||
panel_ui.set_clip_rect(ctx.content_rect());
|
||||
panel_ui
|
||||
.response()
|
||||
.widget_info(|| WidgetInfo::new(WidgetType::Panel));
|
||||
|
||||
let inner_response = self.show_inside_dyn(&mut panel_ui, add_contents);
|
||||
let rect = inner_response.response.rect;
|
||||
|
|
|
|||
|
|
@ -505,15 +505,14 @@ impl Window<'_> {
|
|||
|
||||
// First check for resize to avoid frame delay:
|
||||
let last_frame_outer_rect = area.state().rect();
|
||||
let resize_interaction = ctx.with_accessibility_parent(area.id(), || {
|
||||
resize_interaction(
|
||||
ctx,
|
||||
possible,
|
||||
area_layer_id,
|
||||
last_frame_outer_rect,
|
||||
window_frame,
|
||||
)
|
||||
});
|
||||
let resize_interaction = resize_interaction(
|
||||
ctx,
|
||||
possible,
|
||||
area.id(),
|
||||
area_layer_id,
|
||||
last_frame_outer_rect,
|
||||
window_frame,
|
||||
);
|
||||
|
||||
{
|
||||
let margins = window_frame.total_margin().sum()
|
||||
|
|
@ -538,109 +537,107 @@ impl Window<'_> {
|
|||
}
|
||||
|
||||
let content_inner = {
|
||||
ctx.with_accessibility_parent(area.id(), || {
|
||||
// BEGIN FRAME --------------------------------
|
||||
let mut frame = window_frame.begin(&mut area_content_ui);
|
||||
// BEGIN FRAME --------------------------------
|
||||
let mut frame = window_frame.begin(&mut area_content_ui);
|
||||
|
||||
let show_close_button = open.is_some();
|
||||
let show_close_button = open.is_some();
|
||||
|
||||
let where_to_put_header_background = &area_content_ui.painter().add(Shape::Noop);
|
||||
let where_to_put_header_background = &area_content_ui.painter().add(Shape::Noop);
|
||||
|
||||
let title_bar = if with_title_bar {
|
||||
let title_bar = TitleBar::new(
|
||||
&frame.content_ui,
|
||||
title,
|
||||
show_close_button,
|
||||
collapsible,
|
||||
window_frame,
|
||||
title_bar_height_with_margin,
|
||||
);
|
||||
resize.min_size.x = resize.min_size.x.at_least(title_bar.inner_rect.width()); // Prevent making window smaller than title bar width
|
||||
|
||||
frame.content_ui.set_min_size(title_bar.inner_rect.size());
|
||||
|
||||
// Skip the title bar (and separator):
|
||||
if is_collapsed {
|
||||
frame.content_ui.add_space(title_bar.inner_rect.height());
|
||||
} else {
|
||||
frame.content_ui.add_space(
|
||||
title_bar.inner_rect.height()
|
||||
+ title_content_spacing
|
||||
+ window_frame.inner_margin.sum().y,
|
||||
);
|
||||
}
|
||||
|
||||
Some(title_bar)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (content_inner, content_response) = collapsing
|
||||
.show_body_unindented(&mut frame.content_ui, |ui| {
|
||||
resize.show(ui, |ui| {
|
||||
if scroll.is_any_scroll_enabled() {
|
||||
scroll.show(ui, add_contents).inner
|
||||
} else {
|
||||
add_contents(ui)
|
||||
}
|
||||
})
|
||||
})
|
||||
.map_or((None, None), |ir| (Some(ir.inner), Some(ir.response)));
|
||||
|
||||
let outer_rect = frame.end(&mut area_content_ui).rect;
|
||||
paint_resize_corner(
|
||||
&area_content_ui,
|
||||
&possible,
|
||||
outer_rect,
|
||||
&window_frame,
|
||||
resize_interaction,
|
||||
let title_bar = if with_title_bar {
|
||||
let title_bar = TitleBar::new(
|
||||
&frame.content_ui,
|
||||
title,
|
||||
show_close_button,
|
||||
collapsible,
|
||||
window_frame,
|
||||
title_bar_height_with_margin,
|
||||
);
|
||||
resize.min_size.x = resize.min_size.x.at_least(title_bar.inner_rect.width()); // Prevent making window smaller than title bar width
|
||||
|
||||
// END FRAME --------------------------------
|
||||
frame.content_ui.set_min_size(title_bar.inner_rect.size());
|
||||
|
||||
if let Some(mut title_bar) = title_bar {
|
||||
title_bar.inner_rect = outer_rect.shrink(window_frame.stroke.width);
|
||||
title_bar.inner_rect.max.y =
|
||||
title_bar.inner_rect.min.y + title_bar_height_with_margin;
|
||||
|
||||
if on_top && area_content_ui.visuals().window_highlight_topmost {
|
||||
let mut round =
|
||||
window_frame.corner_radius - window_frame.stroke.width.round() as u8;
|
||||
|
||||
if !is_collapsed {
|
||||
round.se = 0;
|
||||
round.sw = 0;
|
||||
}
|
||||
|
||||
area_content_ui.painter().set(
|
||||
*where_to_put_header_background,
|
||||
RectShape::filled(title_bar.inner_rect, round, header_color),
|
||||
);
|
||||
}
|
||||
|
||||
if false {
|
||||
ctx.debug_painter().debug_rect(
|
||||
title_bar.inner_rect,
|
||||
Color32::LIGHT_BLUE,
|
||||
"title_bar.rect",
|
||||
);
|
||||
}
|
||||
|
||||
title_bar.ui(
|
||||
&mut area_content_ui,
|
||||
&content_response,
|
||||
open.as_deref_mut(),
|
||||
&mut collapsing,
|
||||
collapsible,
|
||||
// Skip the title bar (and separator):
|
||||
if is_collapsed {
|
||||
frame.content_ui.add_space(title_bar.inner_rect.height());
|
||||
} else {
|
||||
frame.content_ui.add_space(
|
||||
title_bar.inner_rect.height()
|
||||
+ title_content_spacing
|
||||
+ window_frame.inner_margin.sum().y,
|
||||
);
|
||||
}
|
||||
|
||||
collapsing.store(ctx);
|
||||
Some(title_bar)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
paint_frame_interaction(&area_content_ui, outer_rect, resize_interaction);
|
||||
let (content_inner, content_response) = collapsing
|
||||
.show_body_unindented(&mut frame.content_ui, |ui| {
|
||||
resize.show(ui, |ui| {
|
||||
if scroll.is_any_scroll_enabled() {
|
||||
scroll.show(ui, add_contents).inner
|
||||
} else {
|
||||
add_contents(ui)
|
||||
}
|
||||
})
|
||||
})
|
||||
.map_or((None, None), |ir| (Some(ir.inner), Some(ir.response)));
|
||||
|
||||
content_inner
|
||||
})
|
||||
let outer_rect = frame.end(&mut area_content_ui).rect;
|
||||
paint_resize_corner(
|
||||
&area_content_ui,
|
||||
&possible,
|
||||
outer_rect,
|
||||
&window_frame,
|
||||
resize_interaction,
|
||||
);
|
||||
|
||||
// END FRAME --------------------------------
|
||||
|
||||
if let Some(mut title_bar) = title_bar {
|
||||
title_bar.inner_rect = outer_rect.shrink(window_frame.stroke.width);
|
||||
title_bar.inner_rect.max.y =
|
||||
title_bar.inner_rect.min.y + title_bar_height_with_margin;
|
||||
|
||||
if on_top && area_content_ui.visuals().window_highlight_topmost {
|
||||
let mut round =
|
||||
window_frame.corner_radius - window_frame.stroke.width.round() as u8;
|
||||
|
||||
if !is_collapsed {
|
||||
round.se = 0;
|
||||
round.sw = 0;
|
||||
}
|
||||
|
||||
area_content_ui.painter().set(
|
||||
*where_to_put_header_background,
|
||||
RectShape::filled(title_bar.inner_rect, round, header_color),
|
||||
);
|
||||
}
|
||||
|
||||
if false {
|
||||
ctx.debug_painter().debug_rect(
|
||||
title_bar.inner_rect,
|
||||
Color32::LIGHT_BLUE,
|
||||
"title_bar.rect",
|
||||
);
|
||||
}
|
||||
|
||||
title_bar.ui(
|
||||
&mut area_content_ui,
|
||||
&content_response,
|
||||
open.as_deref_mut(),
|
||||
&mut collapsing,
|
||||
collapsible,
|
||||
);
|
||||
}
|
||||
|
||||
collapsing.store(ctx);
|
||||
|
||||
paint_frame_interaction(&area_content_ui, outer_rect, resize_interaction);
|
||||
|
||||
content_inner
|
||||
};
|
||||
|
||||
let full_response = area.end(ctx, area_content_ui);
|
||||
|
|
@ -882,6 +879,7 @@ fn move_and_resize_window(ctx: &Context, interaction: &ResizeInteraction) -> Opt
|
|||
fn resize_interaction(
|
||||
ctx: &Context,
|
||||
possible: PossibleInteractions,
|
||||
_accessibility_parent: Id,
|
||||
layer_id: LayerId,
|
||||
outer_rect: Rect,
|
||||
window_frame: Frame,
|
||||
|
|
@ -901,6 +899,8 @@ fn resize_interaction(
|
|||
let rect = outer_rect.shrink(window_frame.stroke.width / 2.0);
|
||||
|
||||
let side_response = |rect, id| {
|
||||
#[cfg(feature = "accesskit")]
|
||||
ctx.register_accesskit_parent(id, _accessibility_parent);
|
||||
let response = ctx.create_widget(
|
||||
WidgetRect {
|
||||
layer_id,
|
||||
|
|
|
|||
|
|
@ -519,7 +519,7 @@ impl ContextImpl {
|
|||
nodes.insert(id, root_node);
|
||||
viewport.this_pass.accesskit_state = Some(AccessKitPassState {
|
||||
nodes,
|
||||
parent_stack: vec![id],
|
||||
parent_map: IdMap::default(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -595,8 +595,28 @@ impl ContextImpl {
|
|||
let builders = &mut state.nodes;
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = builders.entry(id) {
|
||||
entry.insert(Default::default());
|
||||
let parent_id = state.parent_stack.last().unwrap();
|
||||
let parent_builder = builders.get_mut(parent_id).unwrap();
|
||||
|
||||
/// Find the first ancestor that already has an accesskit node.
|
||||
fn find_accesskit_parent(
|
||||
parent_map: &IdMap<Id>,
|
||||
node_map: &IdMap<accesskit::Node>,
|
||||
id: Id,
|
||||
) -> Option<Id> {
|
||||
if let Some(parent_id) = parent_map.get(&id) {
|
||||
if node_map.contains_key(parent_id) {
|
||||
Some(*parent_id)
|
||||
} else {
|
||||
find_accesskit_parent(parent_map, node_map, *parent_id)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let parent_id = find_accesskit_parent(&state.parent_map, builders, id)
|
||||
.unwrap_or(crate::accesskit_root_id());
|
||||
|
||||
let parent_builder = builders.get_mut(&parent_id).unwrap();
|
||||
parent_builder.push_child(id.accesskit_id());
|
||||
}
|
||||
builders.get_mut(&id).unwrap()
|
||||
|
|
@ -3464,43 +3484,10 @@ impl Context {
|
|||
|
||||
/// ## Accessibility
|
||||
impl Context {
|
||||
/// Call the provided function with the given ID pushed on the stack of
|
||||
/// parent IDs for accessibility purposes. If the `accesskit` feature
|
||||
/// is disabled or if AccessKit support is not active for this frame,
|
||||
/// the function is still called, but with no other effect.
|
||||
///
|
||||
/// No locks are held while the given closure is called.
|
||||
#[allow(clippy::unused_self, clippy::let_and_return, clippy::allow_attributes)]
|
||||
#[inline]
|
||||
pub fn with_accessibility_parent<R>(&self, _id: Id, f: impl FnOnce() -> R) -> R {
|
||||
// TODO(emilk): this isn't thread-safe - another thread can call this function between the push/pop calls
|
||||
#[cfg(feature = "accesskit")]
|
||||
self.pass_state_mut(|fs| {
|
||||
if let Some(state) = fs.accesskit_state.as_mut() {
|
||||
state.parent_stack.push(_id);
|
||||
}
|
||||
});
|
||||
|
||||
let result = f();
|
||||
|
||||
#[cfg(feature = "accesskit")]
|
||||
self.pass_state_mut(|fs| {
|
||||
if let Some(state) = fs.accesskit_state.as_mut() {
|
||||
assert_eq!(
|
||||
state.parent_stack.pop(),
|
||||
Some(_id),
|
||||
"Mismatched push/pop in with_accessibility_parent"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// If AccessKit support is active for the current frame, get or create
|
||||
/// a node builder with the specified ID and return a mutable reference to it.
|
||||
/// For newly created nodes, the parent is the node with the ID at the top
|
||||
/// of the stack managed by [`Context::with_accessibility_parent`].
|
||||
/// For newly created nodes, the parent is the parent [`Ui`]s ID.
|
||||
/// And an [`Ui`]s parent can be set with [`crate::UiBuilder::accessibility_parent`].
|
||||
///
|
||||
/// The `Context` lock is held while the given closure is called!
|
||||
///
|
||||
|
|
@ -3522,6 +3509,15 @@ impl Context {
|
|||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "accesskit")]
|
||||
pub(crate) fn register_accesskit_parent(&self, id: Id, parent_id: Id) {
|
||||
self.write(|ctx| {
|
||||
if let Some(state) = ctx.viewport().this_pass.accesskit_state.as_mut() {
|
||||
state.parent_map.insert(id, parent_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Enable generation of AccessKit tree updates in all future frames.
|
||||
#[cfg(feature = "accesskit")]
|
||||
pub fn enable_accesskit(&self) {
|
||||
|
|
|
|||
|
|
@ -682,6 +682,7 @@ impl WidgetInfo {
|
|||
WidgetType::ColorButton => "color button",
|
||||
WidgetType::Image => "image",
|
||||
WidgetType::CollapsingHeader => "collapsing header",
|
||||
WidgetType::Panel => "panel",
|
||||
WidgetType::ProgressIndicator => "progress indicator",
|
||||
WidgetType::Window => "window",
|
||||
WidgetType::Label | WidgetType::Other => "",
|
||||
|
|
|
|||
|
|
@ -674,6 +674,8 @@ pub enum WidgetType {
|
|||
|
||||
CollapsingHeader,
|
||||
|
||||
Panel,
|
||||
|
||||
ProgressIndicator,
|
||||
|
||||
Window,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ impl ScrollTarget {
|
|||
#[derive(Clone)]
|
||||
pub struct AccessKitPassState {
|
||||
pub nodes: IdMap<accesskit::Node>,
|
||||
pub parent_stack: Vec<Id>,
|
||||
pub parent_map: IdMap<Id>,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
|
|
|
|||
|
|
@ -858,6 +858,7 @@ impl Response {
|
|||
WidgetType::Slider => Role::Slider,
|
||||
WidgetType::DragValue => Role::SpinButton,
|
||||
WidgetType::ColorButton => Role::ColorWell,
|
||||
WidgetType::Panel => Role::Pane,
|
||||
WidgetType::ProgressIndicator => Role::ProgressIndicator,
|
||||
WidgetType::Window => Role::Window,
|
||||
WidgetType::Other => Role::Unknown,
|
||||
|
|
|
|||
|
|
@ -40,60 +40,60 @@ pub fn update_accesskit_for_text_widget(
|
|||
return;
|
||||
};
|
||||
|
||||
ctx.with_accessibility_parent(parent_id, || {
|
||||
for (row_index, row) in galley.rows.iter().enumerate() {
|
||||
let row_id = parent_id.with(row_index);
|
||||
ctx.accesskit_node_builder(row_id, |builder| {
|
||||
builder.set_role(accesskit::Role::TextRun);
|
||||
let rect = global_from_galley * row.rect_without_leading_space();
|
||||
builder.set_bounds(accesskit::Rect {
|
||||
x0: rect.min.x.into(),
|
||||
y0: rect.min.y.into(),
|
||||
x1: rect.max.x.into(),
|
||||
y1: rect.max.y.into(),
|
||||
});
|
||||
builder.set_text_direction(accesskit::TextDirection::LeftToRight);
|
||||
// TODO(mwcampbell): Set more node fields for the row
|
||||
// once AccessKit adapters expose text formatting info.
|
||||
|
||||
let glyph_count = row.glyphs.len();
|
||||
let mut value = String::new();
|
||||
value.reserve(glyph_count);
|
||||
let mut character_lengths = Vec::<u8>::with_capacity(glyph_count);
|
||||
let mut character_positions = Vec::<f32>::with_capacity(glyph_count);
|
||||
let mut character_widths = Vec::<f32>::with_capacity(glyph_count);
|
||||
let mut word_lengths = Vec::<u8>::new();
|
||||
let mut was_at_word_end = false;
|
||||
let mut last_word_start = 0usize;
|
||||
|
||||
for glyph in &row.glyphs {
|
||||
let is_word_char = is_word_char(glyph.chr);
|
||||
if is_word_char && was_at_word_end {
|
||||
word_lengths.push((character_lengths.len() - last_word_start) as _);
|
||||
last_word_start = character_lengths.len();
|
||||
}
|
||||
was_at_word_end = !is_word_char;
|
||||
let old_len = value.len();
|
||||
value.push(glyph.chr);
|
||||
character_lengths.push((value.len() - old_len) as _);
|
||||
character_positions.push(glyph.pos.x - row.pos.x);
|
||||
character_widths.push(glyph.advance_width);
|
||||
}
|
||||
|
||||
if row.ends_with_newline {
|
||||
value.push('\n');
|
||||
character_lengths.push(1);
|
||||
character_positions.push(row.size.x);
|
||||
character_widths.push(0.0);
|
||||
}
|
||||
word_lengths.push((character_lengths.len() - last_word_start) as _);
|
||||
|
||||
builder.set_value(value);
|
||||
builder.set_character_lengths(character_lengths);
|
||||
builder.set_character_positions(character_positions);
|
||||
builder.set_character_widths(character_widths);
|
||||
builder.set_word_lengths(word_lengths);
|
||||
for (row_index, row) in galley.rows.iter().enumerate() {
|
||||
let row_id = parent_id.with(row_index);
|
||||
#[cfg(feature = "accesskit")]
|
||||
ctx.register_accesskit_parent(row_id, parent_id);
|
||||
ctx.accesskit_node_builder(row_id, |builder| {
|
||||
builder.set_role(accesskit::Role::TextRun);
|
||||
let rect = global_from_galley * row.rect_without_leading_space();
|
||||
builder.set_bounds(accesskit::Rect {
|
||||
x0: rect.min.x.into(),
|
||||
y0: rect.min.y.into(),
|
||||
x1: rect.max.x.into(),
|
||||
y1: rect.max.y.into(),
|
||||
});
|
||||
}
|
||||
});
|
||||
builder.set_text_direction(accesskit::TextDirection::LeftToRight);
|
||||
// TODO(mwcampbell): Set more node fields for the row
|
||||
// once AccessKit adapters expose text formatting info.
|
||||
|
||||
let glyph_count = row.glyphs.len();
|
||||
let mut value = String::new();
|
||||
value.reserve(glyph_count);
|
||||
let mut character_lengths = Vec::<u8>::with_capacity(glyph_count);
|
||||
let mut character_positions = Vec::<f32>::with_capacity(glyph_count);
|
||||
let mut character_widths = Vec::<f32>::with_capacity(glyph_count);
|
||||
let mut word_lengths = Vec::<u8>::new();
|
||||
let mut was_at_word_end = false;
|
||||
let mut last_word_start = 0usize;
|
||||
|
||||
for glyph in &row.glyphs {
|
||||
let is_word_char = is_word_char(glyph.chr);
|
||||
if is_word_char && was_at_word_end {
|
||||
word_lengths.push((character_lengths.len() - last_word_start) as _);
|
||||
last_word_start = character_lengths.len();
|
||||
}
|
||||
was_at_word_end = !is_word_char;
|
||||
let old_len = value.len();
|
||||
value.push(glyph.chr);
|
||||
character_lengths.push((value.len() - old_len) as _);
|
||||
character_positions.push(glyph.pos.x - row.pos.x);
|
||||
character_widths.push(glyph.advance_width);
|
||||
}
|
||||
|
||||
if row.ends_with_newline {
|
||||
value.push('\n');
|
||||
character_lengths.push(1);
|
||||
character_positions.push(row.size.x);
|
||||
character_widths.push(0.0);
|
||||
}
|
||||
word_lengths.push((character_lengths.len() - last_word_start) as _);
|
||||
|
||||
builder.set_value(value);
|
||||
builder.set_character_lengths(character_lengths);
|
||||
builder.set_character_positions(character_positions);
|
||||
builder.set_character_widths(character_widths);
|
||||
builder.set_word_lengths(word_lengths);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ impl Ui {
|
|||
sizing_pass,
|
||||
style,
|
||||
sense,
|
||||
#[cfg(feature = "accesskit")]
|
||||
accessibility_parent,
|
||||
} = ui_builder;
|
||||
|
||||
let layer_id = layer_id.unwrap_or(LayerId::background());
|
||||
|
|
@ -173,6 +175,12 @@ impl Ui {
|
|||
min_rect_already_remembered: false,
|
||||
};
|
||||
|
||||
#[cfg(feature = "accesskit")]
|
||||
if let Some(accessibility_parent) = accessibility_parent {
|
||||
ui.ctx()
|
||||
.register_accesskit_parent(ui.unique_id, accessibility_parent);
|
||||
}
|
||||
|
||||
// Register in the widget stack early, to ensure we are behind all widgets we contain:
|
||||
let start_rect = Rect::NOTHING; // This will be overwritten when `remember_min_rect` is called
|
||||
ui.ctx().create_widget(
|
||||
|
|
@ -194,6 +202,11 @@ impl Ui {
|
|||
ui.set_invisible();
|
||||
}
|
||||
|
||||
#[cfg(feature = "accesskit")]
|
||||
ui.ctx().accesskit_node_builder(ui.unique_id, |node| {
|
||||
node.set_role(accesskit::Role::GenericContainer);
|
||||
});
|
||||
|
||||
ui
|
||||
}
|
||||
|
||||
|
|
@ -260,6 +273,8 @@ impl Ui {
|
|||
sizing_pass,
|
||||
style,
|
||||
sense,
|
||||
#[cfg(feature = "accesskit")]
|
||||
accessibility_parent,
|
||||
} = ui_builder;
|
||||
|
||||
let mut painter = self.painter.clone();
|
||||
|
|
@ -328,6 +343,12 @@ impl Ui {
|
|||
child_ui.disable();
|
||||
}
|
||||
|
||||
#[cfg(feature = "accesskit")]
|
||||
child_ui.ctx().register_accesskit_parent(
|
||||
child_ui.unique_id,
|
||||
accessibility_parent.unwrap_or(self.unique_id),
|
||||
);
|
||||
|
||||
// Register in the widget stack early, to ensure we are behind all widgets we contain:
|
||||
let start_rect = Rect::NOTHING; // This will be overwritten when `remember_min_rect` is called
|
||||
child_ui.ctx().create_widget(
|
||||
|
|
@ -342,6 +363,13 @@ impl Ui {
|
|||
true,
|
||||
);
|
||||
|
||||
#[cfg(feature = "accesskit")]
|
||||
child_ui
|
||||
.ctx()
|
||||
.accesskit_node_builder(child_ui.unique_id, |node| {
|
||||
node.set_role(accesskit::Role::GenericContainer);
|
||||
});
|
||||
|
||||
child_ui
|
||||
}
|
||||
|
||||
|
|
@ -1101,6 +1129,9 @@ impl Ui {
|
|||
impl Ui {
|
||||
/// Check for clicks, drags and/or hover on a specific region of this [`Ui`].
|
||||
pub fn interact(&self, rect: Rect, id: Id, sense: Sense) -> Response {
|
||||
#[cfg(feature = "accesskit")]
|
||||
self.ctx().register_accesskit_parent(id, self.unique_id);
|
||||
|
||||
self.ctx().create_widget(
|
||||
WidgetRect {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub struct UiBuilder {
|
|||
pub sizing_pass: bool,
|
||||
pub style: Option<Arc<Style>>,
|
||||
pub sense: Option<Sense>,
|
||||
#[cfg(feature = "accesskit")]
|
||||
pub accessibility_parent: Option<Id>,
|
||||
}
|
||||
|
||||
impl UiBuilder {
|
||||
|
|
@ -180,4 +182,20 @@ impl UiBuilder {
|
|||
.insert(ClosableTag::NAME, Some(Arc::new(ClosableTag::default())));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the accessibility parent for this [`Ui`].
|
||||
///
|
||||
/// This will override the automatic parent assignment for accessibility purposes.
|
||||
/// If not set, the parent [`Ui`]'s ID will be used as the accessibility parent.
|
||||
///
|
||||
/// This does nothing if the `accesskit` feature is not enabled.
|
||||
#[cfg_attr(not(feature = "accesskit"), expect(unused_mut, unused_variables))]
|
||||
#[inline]
|
||||
pub fn accessibility_parent(mut self, parent_id: Id) -> Self {
|
||||
#[cfg(feature = "accesskit")]
|
||||
{
|
||||
self.accessibility_parent = Some(parent_id);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,23 +87,21 @@ impl egui::Plugin for AccessibilityInspectorPlugin {
|
|||
ctx.enable_accesskit();
|
||||
|
||||
SidePanel::right(Self::id()).show(ctx, |ui| {
|
||||
let response = ui.heading("🔎 AccessKit Inspector");
|
||||
ctx.with_accessibility_parent(response.id, || {
|
||||
if let Some(selected_node) = self.selected_node {
|
||||
TopBottomPanel::bottom(Self::id().with("details_panel"))
|
||||
.frame(Frame::new())
|
||||
.show_separator_line(false)
|
||||
.show_inside(ui, |ui| {
|
||||
self.selection_ui(ui, selected_node);
|
||||
});
|
||||
}
|
||||
ui.heading("🔎 AccessKit Inspector");
|
||||
if let Some(selected_node) = self.selected_node {
|
||||
TopBottomPanel::bottom(Self::id().with("details_panel"))
|
||||
.frame(Frame::new())
|
||||
.show_separator_line(false)
|
||||
.show_inside(ui, |ui| {
|
||||
self.selection_ui(ui, selected_node);
|
||||
});
|
||||
}
|
||||
|
||||
ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate);
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
if let Some(tree) = &self.tree {
|
||||
Self::node_ui(ui, &tree.state().root(), &mut self.selected_node);
|
||||
}
|
||||
});
|
||||
ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate);
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
if let Some(tree) = &self.tree {
|
||||
Self::node_ui(ui, &tree.state().root(), &mut self.selected_node);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -157,6 +155,7 @@ impl AccessibilityInspectorPlugin {
|
|||
|
||||
ui.label("Children");
|
||||
ui.label(RichText::new(node.children().len().to_string()).strong());
|
||||
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,20 @@ fn empty_ui_should_return_tree_with_only_root_window() {
|
|||
|
||||
assert_eq!(
|
||||
output.nodes.len(),
|
||||
1,
|
||||
"Empty ui should produce only the root window."
|
||||
4,
|
||||
"Expected the root node and two Uis and a Frame for the panel"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
output
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| n.role() == Role::GenericContainer)
|
||||
.count(),
|
||||
3,
|
||||
"Expected two Uis and one Frame as GenericContainer nodes.",
|
||||
);
|
||||
|
||||
let (id, root) = &output.nodes[0];
|
||||
|
||||
assert_eq!(*id, output.tree.unwrap().root);
|
||||
|
|
@ -35,12 +46,6 @@ fn button_node() {
|
|||
CentralPanel::default().show(ctx, |ui| ui.button(button_text));
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
output.nodes.len(),
|
||||
2,
|
||||
"Expected only the root node and the button."
|
||||
);
|
||||
|
||||
let (_, button) = output
|
||||
.nodes
|
||||
.iter()
|
||||
|
|
@ -61,12 +66,6 @@ fn disabled_button_node() {
|
|||
});
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
output.nodes.len(),
|
||||
2,
|
||||
"Expected only the root node and the button."
|
||||
);
|
||||
|
||||
let (_, button) = output
|
||||
.nodes
|
||||
.iter()
|
||||
|
|
@ -86,12 +85,6 @@ fn toggle_button_node() {
|
|||
CentralPanel::default().show(ctx, |ui| ui.toggle_value(&mut selected, button_text));
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
output.nodes.len(),
|
||||
2,
|
||||
"Expected only the root node and the button."
|
||||
);
|
||||
|
||||
let (_, toggle) = output
|
||||
.nodes
|
||||
.iter()
|
||||
|
|
@ -114,12 +107,6 @@ fn multiple_disabled_widgets() {
|
|||
});
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
output.nodes.len(),
|
||||
4,
|
||||
"Expected the root node and all the child widgets."
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
output
|
||||
.nodes
|
||||
|
|
@ -194,15 +181,25 @@ fn assert_window_exists(tree: &TreeUpdate, title: &str, parent: NodeId) -> NodeI
|
|||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_parent_child(tree: &TreeUpdate, parent: NodeId, child: NodeId) {
|
||||
fn assert_parent_child(tree: &TreeUpdate, parent_id: NodeId, child: NodeId) {
|
||||
assert!(
|
||||
has_child_recursively(tree, parent_id, child),
|
||||
"Node is not a child of the given parent."
|
||||
);
|
||||
}
|
||||
|
||||
fn has_child_recursively(tree: &TreeUpdate, parent: NodeId, child: NodeId) -> bool {
|
||||
let (_, parent) = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(id, _)| id == &parent)
|
||||
.expect("Parent does not exist.");
|
||||
|
||||
assert!(
|
||||
parent.children().contains(&child),
|
||||
"Node is not a child of the given parent."
|
||||
);
|
||||
for &c in parent.children() {
|
||||
if c == child || has_child_recursively(tree, c, child) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use egui::accesskit::Role;
|
||||
use egui::load::SizedTexture;
|
||||
use egui::{
|
||||
Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, DragValue, Event,
|
||||
|
|
@ -277,8 +278,8 @@ impl<'a> VisualTests<'a> {
|
|||
node.hover();
|
||||
});
|
||||
self.add("pressed", |harness| {
|
||||
harness.get_next().hover();
|
||||
let rect = harness.get_next().rect();
|
||||
harness.get_next_widget().hover();
|
||||
let rect = harness.get_next_widget().rect();
|
||||
harness.input_mut().events.push(Event::PointerButton {
|
||||
button: PointerButton::Primary,
|
||||
pos: rect.center(),
|
||||
|
|
@ -329,7 +330,7 @@ impl<'a> VisualTests<'a> {
|
|||
|
||||
pub fn add_node(&mut self, name: &str, test: impl FnOnce(&Node<'_>)) {
|
||||
self.add(name, |harness| {
|
||||
let node = harness.get_next();
|
||||
let node = harness.get_next_widget();
|
||||
test(&node);
|
||||
});
|
||||
}
|
||||
|
|
@ -375,11 +376,13 @@ impl<'a> VisualTests<'a> {
|
|||
}
|
||||
|
||||
trait HarnessExt {
|
||||
fn get_next(&self) -> Node<'_>;
|
||||
fn get_next_widget(&self) -> Node<'_>;
|
||||
}
|
||||
|
||||
impl HarnessExt for Harness<'_> {
|
||||
fn get_next(&self) -> Node<'_> {
|
||||
self.get_all(by()).next().unwrap()
|
||||
fn get_next_widget(&self) -> Node<'_> {
|
||||
self.get_all(by().predicate(|node| node.role() != Role::GenericContainer))
|
||||
.next()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue