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:
Lucas Meurer 2025-10-08 11:30:32 +02:00 committed by GitHub
parent 6a49c9ad6b
commit 3fdc5641aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 302 additions and 249 deletions

View File

@ -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 {

View File

@ -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;

View File

@ -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,

View File

@ -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) {

View File

@ -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 => "",

View File

@ -674,6 +674,8 @@ pub enum WidgetType {
CollapsingHeader,
Panel,
ProgressIndicator,
Window,

View File

@ -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)]

View File

@ -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,

View File

@ -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);
});
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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();
});

View File

@ -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
}

View File

@ -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()
}
}