Skip to content

Commit ebbbda1

Browse files
authored
[A11y] Add Semantics tree node details (#10018)
1 parent efbfeba commit ebbbda1

8 files changed

Lines changed: 706 additions & 96 deletions

File tree

‎packages/devtools_app/lib/devtools_app.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export 'src/framework/scaffold/app_bar.dart';
1212
export 'src/framework/scaffold/status_line.dart';
1313
export 'src/screens/accessibility/accessibility_controller.dart';
1414
export 'src/screens/accessibility/accessibility_screen.dart';
15+
export 'src/screens/accessibility/semantics_node_details_pane.dart';
1516
export 'src/screens/accessibility/semantics_node_model.dart';
1617
export 'src/screens/app_size/app_size_controller.dart';
1718
export 'src/screens/app_size/app_size_screen.dart';

‎packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ class AccessibilityController extends DevToolsScreenController
5555
addAutoDisposeListener(boldText, _onBoldTextChanged);
5656
addAutoDisposeListener(screenReader, _onScreenReaderChanged);
5757
addAutoDisposeListener(highContrast, _onHighContrastChanged);
58+
addAutoDisposeListener(semanticsRoots, _onSemanticsRootsChanged);
59+
}
60+
61+
void _onSemanticsRootsChanged() {
62+
selectSemanticsNode(semanticsRoots.value.firstOrNull);
5863
}
5964

6065
void _initSemanticsTree() {
@@ -148,10 +153,19 @@ class AccessibilityController extends DevToolsScreenController
148153
final highContrast = ValueNotifier<bool>(false);
149154

150155
final semanticsRoots = ValueNotifier<List<SemanticsNodeModel>>([]);
156+
final selectedSemanticsNode = ValueNotifier<SemanticsNodeModel?>(null);
151157
final semanticsTreeLoading = ValueNotifier<bool>(false);
152158
final semanticsTreeError = ValueNotifier<String?>(null);
153159
final treeScrollController = ScrollController();
154160

161+
/// Selects [node] in the semantics tree and unselects any previously selected node.
162+
void selectSemanticsNode(SemanticsNodeModel? node) {
163+
if (selectedSemanticsNode.value == node) return;
164+
selectedSemanticsNode.value?.unselect();
165+
node?.select();
166+
selectedSemanticsNode.value = node;
167+
}
168+
155169
Future<void> loadSemanticsTree() async {
156170
if (semanticsTreeLoading.value) return;
157171

@@ -175,6 +189,8 @@ class AccessibilityController extends DevToolsScreenController
175189
final response = await serviceConnection.serviceManager
176190
.callServiceExtensionOnMainIsolate(registrations.getSemanticsTree);
177191

192+
if (disposed) return;
193+
178194
final json = response.json;
179195
if (json != null && json.containsKey('error')) {
180196
throw Exception(json['error']);
@@ -208,8 +224,10 @@ class AccessibilityController extends DevToolsScreenController
208224
semanticsTreeError.value = null;
209225
} catch (e, st) {
210226
_log.warning('Error loading semantics tree: $e', e, st);
211-
semanticsRoots.value = [];
212-
semanticsTreeError.value = 'Failed to load semantics tree: $e';
227+
if (!disposed) {
228+
semanticsRoots.value = [];
229+
semanticsTreeError.value = 'Failed to load semantics tree: $e';
230+
}
213231
} finally {
214232
if (!disposed) {
215233
semanticsTreeLoading.value = false;
@@ -253,10 +271,14 @@ class AccessibilityController extends DevToolsScreenController
253271
SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
254272
final rawFlags = json['flags'] as List<Object?>?;
255273
final flags = SemanticsNodeModel.parseFlags(rawFlags);
274+
final rect = SemanticsNodeModel.parseRect(json['rect']);
256275

257276
return SemanticsNodeModel(
258277
id: json['id']?.toString() ?? '',
259278
label: json['label']?.toString() ?? '',
279+
value: json['value']?.toString() ?? '',
280+
hint: json['hint']?.toString() ?? '',
281+
rect: rect,
260282
flags: flags,
261283
widgetName: json['widgetName']?.toString() ?? '',
262284
);
@@ -271,6 +293,7 @@ class AccessibilityController extends DevToolsScreenController
271293
screenReader.dispose();
272294
highContrast.dispose();
273295
semanticsRoots.dispose();
296+
selectedSemanticsNode.dispose();
274297
semanticsTreeLoading.dispose();
275298
semanticsTreeError.dispose();
276299
treeScrollController.dispose();

‎packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../../shared/framework/screen.dart';
1010
import '../../shared/globals.dart';
1111
import 'accessibility_controller.dart';
1212
import 'overrides_pane.dart';
13+
import 'semantics_node_details_pane.dart';
1314
import 'semantics_tree_pane.dart';
1415

1516
export 'overrides_pane.dart';
@@ -37,6 +38,12 @@ class _AccessibilityScreenBodyState extends State<AccessibilityScreenBody>
3738
// ignore: unused-code, temporarily ignore since this screen is under active development.
3839
late AccessibilityController controller;
3940

41+
/// The initial split fractions for the three screen panes:
42+
/// - [AccessibilitySemanticsTreePane]: 35%
43+
/// - [SemanticsNodeDetailsPane]: 35%
44+
/// - [AccessibilityOverridesPane]: 30%
45+
static const _initialFractions = [0.35, 0.35, 0.3];
46+
4047
@override
4148
void initState() {
4249
super.initState();
@@ -49,9 +56,10 @@ class _AccessibilityScreenBodyState extends State<AccessibilityScreenBody>
4956
final splitAxis = _splitAxisFor(context);
5057
return SplitPane(
5158
axis: splitAxis,
52-
initialFractions: const [0.6, 0.4],
59+
initialFractions: _initialFractions,
5360
children: const [
5461
AccessibilitySemanticsTreePane(),
62+
SemanticsNodeDetailsPane(),
5563
AccessibilityOverridesPane(),
5664
],
5765
);
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright 2026 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
4+
5+
import 'dart:ui' show SemanticsFlag;
6+
7+
import 'package:devtools_app_shared/ui.dart';
8+
import 'package:material_ui/material_ui.dart';
9+
10+
import '../../shared/globals.dart';
11+
import '../../shared/ui/common_widgets.dart';
12+
import 'accessibility_controller.dart';
13+
import 'semantics_node_model.dart';
14+
15+
/// A pane that displays the details of the currently selected semantics node.
16+
class SemanticsNodeDetailsPane extends StatelessWidget {
17+
const SemanticsNodeDetailsPane({super.key});
18+
19+
static const _paneTitle = 'Semantics Node Details';
20+
static const _emptyMessage =
21+
'Select a node in the semantics tree to view its details.';
22+
23+
@override
24+
Widget build(BuildContext context) {
25+
final controller = screenControllers.lookup<AccessibilityController>();
26+
return DevToolsAreaPane(
27+
header: const AreaPaneHeader(
28+
title: Text(_paneTitle),
29+
roundedTopBorder: false,
30+
includeTopBorder: false,
31+
),
32+
child: ValueListenableBuilder<SemanticsNodeModel?>(
33+
valueListenable: controller.selectedSemanticsNode,
34+
builder: (context, selectedNode, _) {
35+
if (selectedNode == null) {
36+
return const CenteredMessage(message: _emptyMessage);
37+
}
38+
return _SemanticsNodeDetailsContent(node: selectedNode);
39+
},
40+
),
41+
);
42+
}
43+
}
44+
45+
/// Displays the properties and flags of a selected [SemanticsNodeModel].
46+
class _SemanticsNodeDetailsContent extends StatelessWidget {
47+
const _SemanticsNodeDetailsContent({required this.node});
48+
49+
final SemanticsNodeModel node;
50+
51+
static const _labelTitle = 'label';
52+
static const _labelDescription = 'The screen reader announcement text.';
53+
54+
static const _valueTitle = 'value';
55+
static const _valueDescription =
56+
'The current value of a progress indicator, slider, etc.';
57+
58+
static const _hintTitle = 'hint';
59+
static const _hintDescription =
60+
'A brief description of the action that will occur.';
61+
62+
static const _rectTitle = 'rect';
63+
static const _rectDescription =
64+
'The bounding box of the node in logical pixels.';
65+
66+
static const _flagsTitle = 'Flags';
67+
static const _flagsDescription =
68+
'Boolean flags that dictate the semantics behavior.';
69+
70+
@override
71+
Widget build(BuildContext context) {
72+
final theme = Theme.of(context);
73+
return Scrollbar(
74+
child: SingleChildScrollView(
75+
padding: const EdgeInsets.all(defaultSpacing),
76+
child: Column(
77+
crossAxisAlignment: CrossAxisAlignment.start,
78+
children: [
79+
Text('SemanticsNode #${node.id}', style: theme.boldTextStyle),
80+
const SizedBox(height: denseSpacing),
81+
const Divider(),
82+
const SizedBox(height: denseSpacing),
83+
_NodeDetailSection(
84+
title: _labelTitle,
85+
description: _labelDescription,
86+
child: _NodeDetailValueBox(
87+
text: node.label,
88+
highlightText: true,
89+
wrapInQuotes: true,
90+
),
91+
),
92+
const SizedBox(height: defaultSpacing),
93+
_NodeDetailSection(
94+
title: _valueTitle,
95+
description: _valueDescription,
96+
child: _NodeDetailValueBox(text: node.value, wrapInQuotes: true),
97+
),
98+
const SizedBox(height: defaultSpacing),
99+
_NodeDetailSection(
100+
title: _hintTitle,
101+
description: _hintDescription,
102+
child: _NodeDetailValueBox(text: node.hint, wrapInQuotes: true),
103+
),
104+
const SizedBox(height: defaultSpacing),
105+
_NodeDetailSection(
106+
title: _rectTitle,
107+
description: _rectDescription,
108+
child: _NodeDetailValueBox(text: node.rectDisplay),
109+
),
110+
const SizedBox(height: defaultSpacing),
111+
_NodeDetailSection(
112+
title: _flagsTitle,
113+
description: _flagsDescription,
114+
child: node.flags.isEmpty
115+
? const _NodeDetailValueBox(text: null)
116+
: _SemanticsFlagsWrap(flags: node.flags),
117+
),
118+
],
119+
),
120+
),
121+
);
122+
}
123+
}
124+
125+
/// A labeled section in the node details view with a title, description, and content.
126+
class _NodeDetailSection extends StatelessWidget {
127+
const _NodeDetailSection({
128+
required this.title,
129+
required this.description,
130+
required this.child,
131+
});
132+
133+
final String title;
134+
final String description;
135+
final Widget child;
136+
137+
@override
138+
Widget build(BuildContext context) {
139+
final theme = Theme.of(context);
140+
return Column(
141+
crossAxisAlignment: CrossAxisAlignment.start,
142+
children: [
143+
Text(title, style: theme.boldTextStyle),
144+
const SizedBox(height: densePadding),
145+
Text(description, style: theme.subtleTextStyle),
146+
const SizedBox(height: denseSpacing),
147+
child,
148+
],
149+
);
150+
}
151+
}
152+
153+
/// A bordered container that displays a property value or `(empty)` if none is present.
154+
class _NodeDetailValueBox extends StatelessWidget {
155+
const _NodeDetailValueBox({
156+
required this.text,
157+
this.highlightText = false,
158+
this.wrapInQuotes = false,
159+
});
160+
161+
final String? text;
162+
final bool highlightText;
163+
final bool wrapInQuotes;
164+
165+
static const _emptyPlaceholder = '(empty)';
166+
167+
@override
168+
Widget build(BuildContext context) {
169+
final theme = Theme.of(context);
170+
final colorScheme = theme.colorScheme;
171+
final hasValue = text != null && text!.isNotEmpty;
172+
final displayText = hasValue
173+
? (wrapInQuotes ? '"$text"' : text!)
174+
: _emptyPlaceholder;
175+
176+
return Container(
177+
width: double.infinity,
178+
padding: const EdgeInsets.all(defaultSpacing),
179+
decoration: BoxDecoration(
180+
color: colorScheme.onSurface.withValues(alpha: 0.05),
181+
borderRadius: defaultBorderRadius,
182+
border: Border.all(
183+
color: highlightText && hasValue
184+
? colorScheme.primary.withValues(alpha: 0.5)
185+
: theme.focusColor,
186+
),
187+
),
188+
child: SelectableText(
189+
displayText,
190+
style: _textStyle(theme, hasValue: hasValue),
191+
),
192+
);
193+
}
194+
195+
TextStyle _textStyle(ThemeData theme, {required bool hasValue}) {
196+
if (!hasValue) {
197+
return theme.subtleFixedFontStyle;
198+
}
199+
if (highlightText) {
200+
return theme.fixedFontStyle.copyWith(
201+
color: theme.colorScheme.primary,
202+
fontWeight: FontWeight.bold,
203+
);
204+
}
205+
return theme.fixedFontStyle;
206+
}
207+
}
208+
209+
/// A wrapping layout of chips representing active [SemanticsFlag]s on a node.
210+
class _SemanticsFlagsWrap extends StatelessWidget {
211+
const _SemanticsFlagsWrap({required this.flags});
212+
213+
final Set<SemanticsFlag> flags;
214+
215+
@override
216+
Widget build(BuildContext context) {
217+
return Wrap(
218+
spacing: denseSpacing,
219+
runSpacing: denseSpacing,
220+
children: [for (final flag in flags) _SemanticsFlagChip(flag: flag)],
221+
);
222+
}
223+
}
224+
225+
/// A chip widget displaying the name of a single [SemanticsFlag].
226+
class _SemanticsFlagChip extends StatelessWidget {
227+
const _SemanticsFlagChip({required this.flag});
228+
229+
final SemanticsFlag flag;
230+
231+
static const _chipBorderRadius = 4.0;
232+
static const _chipBackgroundAlpha = 0.12;
233+
234+
@override
235+
Widget build(BuildContext context) {
236+
final theme = Theme.of(context);
237+
final colorScheme = theme.colorScheme;
238+
return Container(
239+
padding: const EdgeInsets.symmetric(
240+
horizontal: denseSpacing,
241+
vertical: densePadding,
242+
),
243+
decoration: BoxDecoration(
244+
color: colorScheme.onSurface.withValues(alpha: _chipBackgroundAlpha),
245+
borderRadius: BorderRadius.circular(_chipBorderRadius),
246+
),
247+
child: Text(flag.name, style: theme.fixedFontStyle),
248+
);
249+
}
250+
}

0 commit comments

Comments
 (0)