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)
|
.layer_id(self.layer_id)
|
||||||
.max_rect(max_rect)
|
.max_rect(max_rect)
|
||||||
.layout(self.layout)
|
.layout(self.layout)
|
||||||
|
.accessibility_parent(self.move_response.id)
|
||||||
.closable();
|
.closable();
|
||||||
|
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ use emath::GuiRounding as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Align, Context, CursorIcon, Frame, Id, InnerResponse, LayerId, Layout, NumExt as _, Rangef,
|
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 {
|
fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 {
|
||||||
|
|
@ -390,6 +391,9 @@ impl SidePanel {
|
||||||
.max_rect(available_rect),
|
.max_rect(available_rect),
|
||||||
);
|
);
|
||||||
panel_ui.set_clip_rect(ctx.content_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 inner_response = self.show_inside_dyn(&mut panel_ui, add_contents);
|
||||||
let rect = inner_response.response.rect;
|
let rect = inner_response.response.rect;
|
||||||
|
|
|
||||||
|
|
@ -505,15 +505,14 @@ impl Window<'_> {
|
||||||
|
|
||||||
// First check for resize to avoid frame delay:
|
// First check for resize to avoid frame delay:
|
||||||
let last_frame_outer_rect = area.state().rect();
|
let last_frame_outer_rect = area.state().rect();
|
||||||
let resize_interaction = ctx.with_accessibility_parent(area.id(), || {
|
let resize_interaction = resize_interaction(
|
||||||
resize_interaction(
|
ctx,
|
||||||
ctx,
|
possible,
|
||||||
possible,
|
area.id(),
|
||||||
area_layer_id,
|
area_layer_id,
|
||||||
last_frame_outer_rect,
|
last_frame_outer_rect,
|
||||||
window_frame,
|
window_frame,
|
||||||
)
|
);
|
||||||
});
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let margins = window_frame.total_margin().sum()
|
let margins = window_frame.total_margin().sum()
|
||||||
|
|
@ -538,109 +537,107 @@ impl Window<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let content_inner = {
|
let content_inner = {
|
||||||
ctx.with_accessibility_parent(area.id(), || {
|
// BEGIN FRAME --------------------------------
|
||||||
// BEGIN FRAME --------------------------------
|
let mut frame = window_frame.begin(&mut area_content_ui);
|
||||||
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 = if with_title_bar {
|
||||||
let title_bar = TitleBar::new(
|
let title_bar = TitleBar::new(
|
||||||
&frame.content_ui,
|
&frame.content_ui,
|
||||||
title,
|
title,
|
||||||
show_close_button,
|
show_close_button,
|
||||||
collapsible,
|
collapsible,
|
||||||
window_frame,
|
window_frame,
|
||||||
title_bar_height_with_margin,
|
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,
|
|
||||||
);
|
);
|
||||||
|
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 {
|
// Skip the title bar (and separator):
|
||||||
title_bar.inner_rect = outer_rect.shrink(window_frame.stroke.width);
|
if is_collapsed {
|
||||||
title_bar.inner_rect.max.y =
|
frame.content_ui.add_space(title_bar.inner_rect.height());
|
||||||
title_bar.inner_rect.min.y + title_bar_height_with_margin;
|
} else {
|
||||||
|
frame.content_ui.add_space(
|
||||||
if on_top && area_content_ui.visuals().window_highlight_topmost {
|
title_bar.inner_rect.height()
|
||||||
let mut round =
|
+ title_content_spacing
|
||||||
window_frame.corner_radius - window_frame.stroke.width.round() as u8;
|
+ window_frame.inner_margin.sum().y,
|
||||||
|
|
||||||
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);
|
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);
|
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(
|
fn resize_interaction(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
possible: PossibleInteractions,
|
possible: PossibleInteractions,
|
||||||
|
_accessibility_parent: Id,
|
||||||
layer_id: LayerId,
|
layer_id: LayerId,
|
||||||
outer_rect: Rect,
|
outer_rect: Rect,
|
||||||
window_frame: Frame,
|
window_frame: Frame,
|
||||||
|
|
@ -901,6 +899,8 @@ fn resize_interaction(
|
||||||
let rect = outer_rect.shrink(window_frame.stroke.width / 2.0);
|
let rect = outer_rect.shrink(window_frame.stroke.width / 2.0);
|
||||||
|
|
||||||
let side_response = |rect, id| {
|
let side_response = |rect, id| {
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
ctx.register_accesskit_parent(id, _accessibility_parent);
|
||||||
let response = ctx.create_widget(
|
let response = ctx.create_widget(
|
||||||
WidgetRect {
|
WidgetRect {
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,7 @@ impl ContextImpl {
|
||||||
nodes.insert(id, root_node);
|
nodes.insert(id, root_node);
|
||||||
viewport.this_pass.accesskit_state = Some(AccessKitPassState {
|
viewport.this_pass.accesskit_state = Some(AccessKitPassState {
|
||||||
nodes,
|
nodes,
|
||||||
parent_stack: vec![id],
|
parent_map: IdMap::default(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,8 +595,28 @@ impl ContextImpl {
|
||||||
let builders = &mut state.nodes;
|
let builders = &mut state.nodes;
|
||||||
if let std::collections::hash_map::Entry::Vacant(entry) = builders.entry(id) {
|
if let std::collections::hash_map::Entry::Vacant(entry) = builders.entry(id) {
|
||||||
entry.insert(Default::default());
|
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());
|
parent_builder.push_child(id.accesskit_id());
|
||||||
}
|
}
|
||||||
builders.get_mut(&id).unwrap()
|
builders.get_mut(&id).unwrap()
|
||||||
|
|
@ -3464,43 +3484,10 @@ impl Context {
|
||||||
|
|
||||||
/// ## Accessibility
|
/// ## Accessibility
|
||||||
impl Context {
|
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
|
/// 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.
|
/// 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
|
/// For newly created nodes, the parent is the parent [`Ui`]s ID.
|
||||||
/// of the stack managed by [`Context::with_accessibility_parent`].
|
/// And an [`Ui`]s parent can be set with [`crate::UiBuilder::accessibility_parent`].
|
||||||
///
|
///
|
||||||
/// The `Context` lock is held while the given closure is called!
|
/// 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.
|
/// Enable generation of AccessKit tree updates in all future frames.
|
||||||
#[cfg(feature = "accesskit")]
|
#[cfg(feature = "accesskit")]
|
||||||
pub fn enable_accesskit(&self) {
|
pub fn enable_accesskit(&self) {
|
||||||
|
|
|
||||||
|
|
@ -682,6 +682,7 @@ impl WidgetInfo {
|
||||||
WidgetType::ColorButton => "color button",
|
WidgetType::ColorButton => "color button",
|
||||||
WidgetType::Image => "image",
|
WidgetType::Image => "image",
|
||||||
WidgetType::CollapsingHeader => "collapsing header",
|
WidgetType::CollapsingHeader => "collapsing header",
|
||||||
|
WidgetType::Panel => "panel",
|
||||||
WidgetType::ProgressIndicator => "progress indicator",
|
WidgetType::ProgressIndicator => "progress indicator",
|
||||||
WidgetType::Window => "window",
|
WidgetType::Window => "window",
|
||||||
WidgetType::Label | WidgetType::Other => "",
|
WidgetType::Label | WidgetType::Other => "",
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,8 @@ pub enum WidgetType {
|
||||||
|
|
||||||
CollapsingHeader,
|
CollapsingHeader,
|
||||||
|
|
||||||
|
Panel,
|
||||||
|
|
||||||
ProgressIndicator,
|
ProgressIndicator,
|
||||||
|
|
||||||
Window,
|
Window,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ impl ScrollTarget {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AccessKitPassState {
|
pub struct AccessKitPassState {
|
||||||
pub nodes: IdMap<accesskit::Node>,
|
pub nodes: IdMap<accesskit::Node>,
|
||||||
pub parent_stack: Vec<Id>,
|
pub parent_map: IdMap<Id>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
|
||||||
|
|
@ -858,6 +858,7 @@ impl Response {
|
||||||
WidgetType::Slider => Role::Slider,
|
WidgetType::Slider => Role::Slider,
|
||||||
WidgetType::DragValue => Role::SpinButton,
|
WidgetType::DragValue => Role::SpinButton,
|
||||||
WidgetType::ColorButton => Role::ColorWell,
|
WidgetType::ColorButton => Role::ColorWell,
|
||||||
|
WidgetType::Panel => Role::Pane,
|
||||||
WidgetType::ProgressIndicator => Role::ProgressIndicator,
|
WidgetType::ProgressIndicator => Role::ProgressIndicator,
|
||||||
WidgetType::Window => Role::Window,
|
WidgetType::Window => Role::Window,
|
||||||
WidgetType::Other => Role::Unknown,
|
WidgetType::Other => Role::Unknown,
|
||||||
|
|
|
||||||
|
|
@ -40,60 +40,60 @@ pub fn update_accesskit_for_text_widget(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.with_accessibility_parent(parent_id, || {
|
for (row_index, row) in galley.rows.iter().enumerate() {
|
||||||
for (row_index, row) in galley.rows.iter().enumerate() {
|
let row_id = parent_id.with(row_index);
|
||||||
let row_id = parent_id.with(row_index);
|
#[cfg(feature = "accesskit")]
|
||||||
ctx.accesskit_node_builder(row_id, |builder| {
|
ctx.register_accesskit_parent(row_id, parent_id);
|
||||||
builder.set_role(accesskit::Role::TextRun);
|
ctx.accesskit_node_builder(row_id, |builder| {
|
||||||
let rect = global_from_galley * row.rect_without_leading_space();
|
builder.set_role(accesskit::Role::TextRun);
|
||||||
builder.set_bounds(accesskit::Rect {
|
let rect = global_from_galley * row.rect_without_leading_space();
|
||||||
x0: rect.min.x.into(),
|
builder.set_bounds(accesskit::Rect {
|
||||||
y0: rect.min.y.into(),
|
x0: rect.min.x.into(),
|
||||||
x1: rect.max.x.into(),
|
y0: rect.min.y.into(),
|
||||||
y1: rect.max.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);
|
|
||||||
});
|
});
|
||||||
}
|
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,
|
sizing_pass,
|
||||||
style,
|
style,
|
||||||
sense,
|
sense,
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
accessibility_parent,
|
||||||
} = ui_builder;
|
} = ui_builder;
|
||||||
|
|
||||||
let layer_id = layer_id.unwrap_or(LayerId::background());
|
let layer_id = layer_id.unwrap_or(LayerId::background());
|
||||||
|
|
@ -173,6 +175,12 @@ impl Ui {
|
||||||
min_rect_already_remembered: false,
|
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:
|
// 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
|
let start_rect = Rect::NOTHING; // This will be overwritten when `remember_min_rect` is called
|
||||||
ui.ctx().create_widget(
|
ui.ctx().create_widget(
|
||||||
|
|
@ -194,6 +202,11 @@ impl Ui {
|
||||||
ui.set_invisible();
|
ui.set_invisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
ui.ctx().accesskit_node_builder(ui.unique_id, |node| {
|
||||||
|
node.set_role(accesskit::Role::GenericContainer);
|
||||||
|
});
|
||||||
|
|
||||||
ui
|
ui
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +273,8 @@ impl Ui {
|
||||||
sizing_pass,
|
sizing_pass,
|
||||||
style,
|
style,
|
||||||
sense,
|
sense,
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
accessibility_parent,
|
||||||
} = ui_builder;
|
} = ui_builder;
|
||||||
|
|
||||||
let mut painter = self.painter.clone();
|
let mut painter = self.painter.clone();
|
||||||
|
|
@ -328,6 +343,12 @@ impl Ui {
|
||||||
child_ui.disable();
|
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:
|
// 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
|
let start_rect = Rect::NOTHING; // This will be overwritten when `remember_min_rect` is called
|
||||||
child_ui.ctx().create_widget(
|
child_ui.ctx().create_widget(
|
||||||
|
|
@ -342,6 +363,13 @@ impl Ui {
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
child_ui
|
||||||
|
.ctx()
|
||||||
|
.accesskit_node_builder(child_ui.unique_id, |node| {
|
||||||
|
node.set_role(accesskit::Role::GenericContainer);
|
||||||
|
});
|
||||||
|
|
||||||
child_ui
|
child_ui
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1101,6 +1129,9 @@ impl Ui {
|
||||||
impl Ui {
|
impl Ui {
|
||||||
/// Check for clicks, drags and/or hover on a specific region of this [`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 {
|
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(
|
self.ctx().create_widget(
|
||||||
WidgetRect {
|
WidgetRect {
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ pub struct UiBuilder {
|
||||||
pub sizing_pass: bool,
|
pub sizing_pass: bool,
|
||||||
pub style: Option<Arc<Style>>,
|
pub style: Option<Arc<Style>>,
|
||||||
pub sense: Option<Sense>,
|
pub sense: Option<Sense>,
|
||||||
|
#[cfg(feature = "accesskit")]
|
||||||
|
pub accessibility_parent: Option<Id>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiBuilder {
|
impl UiBuilder {
|
||||||
|
|
@ -180,4 +182,20 @@ impl UiBuilder {
|
||||||
.insert(ClosableTag::NAME, Some(Arc::new(ClosableTag::default())));
|
.insert(ClosableTag::NAME, Some(Arc::new(ClosableTag::default())));
|
||||||
self
|
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();
|
ctx.enable_accesskit();
|
||||||
|
|
||||||
SidePanel::right(Self::id()).show(ctx, |ui| {
|
SidePanel::right(Self::id()).show(ctx, |ui| {
|
||||||
let response = ui.heading("🔎 AccessKit Inspector");
|
ui.heading("🔎 AccessKit Inspector");
|
||||||
ctx.with_accessibility_parent(response.id, || {
|
if let Some(selected_node) = self.selected_node {
|
||||||
if let Some(selected_node) = self.selected_node {
|
TopBottomPanel::bottom(Self::id().with("details_panel"))
|
||||||
TopBottomPanel::bottom(Self::id().with("details_panel"))
|
.frame(Frame::new())
|
||||||
.frame(Frame::new())
|
.show_separator_line(false)
|
||||||
.show_separator_line(false)
|
.show_inside(ui, |ui| {
|
||||||
.show_inside(ui, |ui| {
|
self.selection_ui(ui, selected_node);
|
||||||
self.selection_ui(ui, selected_node);
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate);
|
ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate);
|
||||||
ScrollArea::vertical().show(ui, |ui| {
|
ScrollArea::vertical().show(ui, |ui| {
|
||||||
if let Some(tree) = &self.tree {
|
if let Some(tree) = &self.tree {
|
||||||
Self::node_ui(ui, &tree.state().root(), &mut self.selected_node);
|
Self::node_ui(ui, &tree.state().root(), &mut self.selected_node);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +155,7 @@ impl AccessibilityInspectorPlugin {
|
||||||
|
|
||||||
ui.label("Children");
|
ui.label("Children");
|
||||||
ui.label(RichText::new(node.children().len().to_string()).strong());
|
ui.label(RichText::new(node.children().len().to_string()).strong());
|
||||||
|
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,20 @@ fn empty_ui_should_return_tree_with_only_root_window() {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
output.nodes.len(),
|
output.nodes.len(),
|
||||||
1,
|
4,
|
||||||
"Empty ui should produce only the root window."
|
"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];
|
let (id, root) = &output.nodes[0];
|
||||||
|
|
||||||
assert_eq!(*id, output.tree.unwrap().root);
|
assert_eq!(*id, output.tree.unwrap().root);
|
||||||
|
|
@ -35,12 +46,6 @@ fn button_node() {
|
||||||
CentralPanel::default().show(ctx, |ui| ui.button(button_text));
|
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
|
let (_, button) = output
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.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
|
let (_, button) = output
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -86,12 +85,6 @@ fn toggle_button_node() {
|
||||||
CentralPanel::default().show(ctx, |ui| ui.toggle_value(&mut selected, button_text));
|
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
|
let (_, toggle) = output
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.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!(
|
assert_eq!(
|
||||||
output
|
output
|
||||||
.nodes
|
.nodes
|
||||||
|
|
@ -194,15 +181,25 @@ fn assert_window_exists(tree: &TreeUpdate, title: &str, parent: NodeId) -> NodeI
|
||||||
}
|
}
|
||||||
|
|
||||||
#[track_caller]
|
#[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
|
let (_, parent) = tree
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(id, _)| id == &parent)
|
.find(|(id, _)| id == &parent)
|
||||||
.expect("Parent does not exist.");
|
.expect("Parent does not exist.");
|
||||||
|
|
||||||
assert!(
|
for &c in parent.children() {
|
||||||
parent.children().contains(&child),
|
if c == child || has_child_recursively(tree, c, child) {
|
||||||
"Node is not a child of the given parent."
|
return true;
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use egui::accesskit::Role;
|
||||||
use egui::load::SizedTexture;
|
use egui::load::SizedTexture;
|
||||||
use egui::{
|
use egui::{
|
||||||
Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, DragValue, Event,
|
Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, DragValue, Event,
|
||||||
|
|
@ -277,8 +278,8 @@ impl<'a> VisualTests<'a> {
|
||||||
node.hover();
|
node.hover();
|
||||||
});
|
});
|
||||||
self.add("pressed", |harness| {
|
self.add("pressed", |harness| {
|
||||||
harness.get_next().hover();
|
harness.get_next_widget().hover();
|
||||||
let rect = harness.get_next().rect();
|
let rect = harness.get_next_widget().rect();
|
||||||
harness.input_mut().events.push(Event::PointerButton {
|
harness.input_mut().events.push(Event::PointerButton {
|
||||||
button: PointerButton::Primary,
|
button: PointerButton::Primary,
|
||||||
pos: rect.center(),
|
pos: rect.center(),
|
||||||
|
|
@ -329,7 +330,7 @@ impl<'a> VisualTests<'a> {
|
||||||
|
|
||||||
pub fn add_node(&mut self, name: &str, test: impl FnOnce(&Node<'_>)) {
|
pub fn add_node(&mut self, name: &str, test: impl FnOnce(&Node<'_>)) {
|
||||||
self.add(name, |harness| {
|
self.add(name, |harness| {
|
||||||
let node = harness.get_next();
|
let node = harness.get_next_widget();
|
||||||
test(&node);
|
test(&node);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -375,11 +376,13 @@ impl<'a> VisualTests<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
trait HarnessExt {
|
trait HarnessExt {
|
||||||
fn get_next(&self) -> Node<'_>;
|
fn get_next_widget(&self) -> Node<'_>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HarnessExt for Harness<'_> {
|
impl HarnessExt for Harness<'_> {
|
||||||
fn get_next(&self) -> Node<'_> {
|
fn get_next_widget(&self) -> Node<'_> {
|
||||||
self.get_all(by()).next().unwrap()
|
self.get_all(by().predicate(|node| node.role() != Role::GenericContainer))
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue