[A11y] Add Semantics tree node details (#10018)
diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart
index 5fd07a4..a2e566a 100644
--- a/packages/devtools_app/lib/devtools_app.dart
+++ b/packages/devtools_app/lib/devtools_app.dart
@@ -12,6 +12,7 @@
export 'src/framework/scaffold/status_line.dart';
export 'src/screens/accessibility/accessibility_controller.dart';
export 'src/screens/accessibility/accessibility_screen.dart';
+export 'src/screens/accessibility/semantics_node_details_pane.dart';
export 'src/screens/accessibility/semantics_node_model.dart';
export 'src/screens/app_size/app_size_controller.dart';
export 'src/screens/app_size/app_size_screen.dart';
diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
index 573756a..df02423 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
@@ -55,6 +55,11 @@
addAutoDisposeListener(boldText, _onBoldTextChanged);
addAutoDisposeListener(screenReader, _onScreenReaderChanged);
addAutoDisposeListener(highContrast, _onHighContrastChanged);
+ addAutoDisposeListener(semanticsRoots, _onSemanticsRootsChanged);
+ }
+
+ void _onSemanticsRootsChanged() {
+ selectSemanticsNode(semanticsRoots.value.firstOrNull);
}
void _initSemanticsTree() {
@@ -148,10 +153,19 @@
final highContrast = ValueNotifier<bool>(false);
final semanticsRoots = ValueNotifier<List<SemanticsNodeModel>>([]);
+ final selectedSemanticsNode = ValueNotifier<SemanticsNodeModel?>(null);
final semanticsTreeLoading = ValueNotifier<bool>(false);
final semanticsTreeError = ValueNotifier<String?>(null);
final treeScrollController = ScrollController();
+ /// Selects [node] in the semantics tree and unselects any previously selected node.
+ void selectSemanticsNode(SemanticsNodeModel? node) {
+ if (selectedSemanticsNode.value == node) return;
+ selectedSemanticsNode.value?.unselect();
+ node?.select();
+ selectedSemanticsNode.value = node;
+ }
+
Future<void> loadSemanticsTree() async {
if (semanticsTreeLoading.value) return;
@@ -175,6 +189,8 @@
final response = await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(registrations.getSemanticsTree);
+ if (disposed) return;
+
final json = response.json;
if (json != null && json.containsKey('error')) {
throw Exception(json['error']);
@@ -208,8 +224,10 @@
semanticsTreeError.value = null;
} catch (e, st) {
_log.warning('Error loading semantics tree: $e', e, st);
- semanticsRoots.value = [];
- semanticsTreeError.value = 'Failed to load semantics tree: $e';
+ if (!disposed) {
+ semanticsRoots.value = [];
+ semanticsTreeError.value = 'Failed to load semantics tree: $e';
+ }
} finally {
if (!disposed) {
semanticsTreeLoading.value = false;
@@ -253,10 +271,14 @@
SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
final rawFlags = json['flags'] as List<Object?>?;
final flags = SemanticsNodeModel.parseFlags(rawFlags);
+ final rect = SemanticsNodeModel.parseRect(json['rect']);
return SemanticsNodeModel(
id: json['id']?.toString() ?? '',
label: json['label']?.toString() ?? '',
+ value: json['value']?.toString() ?? '',
+ hint: json['hint']?.toString() ?? '',
+ rect: rect,
flags: flags,
widgetName: json['widgetName']?.toString() ?? '',
);
@@ -271,6 +293,7 @@
screenReader.dispose();
highContrast.dispose();
semanticsRoots.dispose();
+ selectedSemanticsNode.dispose();
semanticsTreeLoading.dispose();
semanticsTreeError.dispose();
treeScrollController.dispose();
diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart
index f32e71e..89e47cc 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart
@@ -10,6 +10,7 @@
import '../../shared/globals.dart';
import 'accessibility_controller.dart';
import 'overrides_pane.dart';
+import 'semantics_node_details_pane.dart';
import 'semantics_tree_pane.dart';
export 'overrides_pane.dart';
@@ -37,6 +38,12 @@
// ignore: unused-code, temporarily ignore since this screen is under active development.
late AccessibilityController controller;
+ /// The initial split fractions for the three screen panes:
+ /// - [AccessibilitySemanticsTreePane]: 35%
+ /// - [SemanticsNodeDetailsPane]: 35%
+ /// - [AccessibilityOverridesPane]: 30%
+ static const _initialFractions = [0.35, 0.35, 0.3];
+
@override
void initState() {
super.initState();
@@ -49,9 +56,10 @@
final splitAxis = _splitAxisFor(context);
return SplitPane(
axis: splitAxis,
- initialFractions: const [0.6, 0.4],
+ initialFractions: _initialFractions,
children: const [
AccessibilitySemanticsTreePane(),
+ SemanticsNodeDetailsPane(),
AccessibilityOverridesPane(),
],
);
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart
new file mode 100644
index 0000000..7202781
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart
@@ -0,0 +1,250 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'dart:ui' show SemanticsFlag;
+
+import 'package:devtools_app_shared/ui.dart';
+import 'package:material_ui/material_ui.dart';
+
+import '../../shared/globals.dart';
+import '../../shared/ui/common_widgets.dart';
+import 'accessibility_controller.dart';
+import 'semantics_node_model.dart';
+
+/// A pane that displays the details of the currently selected semantics node.
+class SemanticsNodeDetailsPane extends StatelessWidget {
+ const SemanticsNodeDetailsPane({super.key});
+
+ static const _paneTitle = 'Semantics Node Details';
+ static const _emptyMessage =
+ 'Select a node in the semantics tree to view its details.';
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = screenControllers.lookup<AccessibilityController>();
+ return DevToolsAreaPane(
+ header: const AreaPaneHeader(
+ title: Text(_paneTitle),
+ roundedTopBorder: false,
+ includeTopBorder: false,
+ ),
+ child: ValueListenableBuilder<SemanticsNodeModel?>(
+ valueListenable: controller.selectedSemanticsNode,
+ builder: (context, selectedNode, _) {
+ if (selectedNode == null) {
+ return const CenteredMessage(message: _emptyMessage);
+ }
+ return _SemanticsNodeDetailsContent(node: selectedNode);
+ },
+ ),
+ );
+ }
+}
+
+/// Displays the properties and flags of a selected [SemanticsNodeModel].
+class _SemanticsNodeDetailsContent extends StatelessWidget {
+ const _SemanticsNodeDetailsContent({required this.node});
+
+ final SemanticsNodeModel node;
+
+ static const _labelTitle = 'label';
+ static const _labelDescription = 'The screen reader announcement text.';
+
+ static const _valueTitle = 'value';
+ static const _valueDescription =
+ 'The current value of a progress indicator, slider, etc.';
+
+ static const _hintTitle = 'hint';
+ static const _hintDescription =
+ 'A brief description of the action that will occur.';
+
+ static const _rectTitle = 'rect';
+ static const _rectDescription =
+ 'The bounding box of the node in logical pixels.';
+
+ static const _flagsTitle = 'Flags';
+ static const _flagsDescription =
+ 'Boolean flags that dictate the semantics behavior.';
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Scrollbar(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(defaultSpacing),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('SemanticsNode #${node.id}', style: theme.boldTextStyle),
+ const SizedBox(height: denseSpacing),
+ const Divider(),
+ const SizedBox(height: denseSpacing),
+ _NodeDetailSection(
+ title: _labelTitle,
+ description: _labelDescription,
+ child: _NodeDetailValueBox(
+ text: node.label,
+ highlightText: true,
+ wrapInQuotes: true,
+ ),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _NodeDetailSection(
+ title: _valueTitle,
+ description: _valueDescription,
+ child: _NodeDetailValueBox(text: node.value, wrapInQuotes: true),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _NodeDetailSection(
+ title: _hintTitle,
+ description: _hintDescription,
+ child: _NodeDetailValueBox(text: node.hint, wrapInQuotes: true),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _NodeDetailSection(
+ title: _rectTitle,
+ description: _rectDescription,
+ child: _NodeDetailValueBox(text: node.rectDisplay),
+ ),
+ const SizedBox(height: defaultSpacing),
+ _NodeDetailSection(
+ title: _flagsTitle,
+ description: _flagsDescription,
+ child: node.flags.isEmpty
+ ? const _NodeDetailValueBox(text: null)
+ : _SemanticsFlagsWrap(flags: node.flags),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// A labeled section in the node details view with a title, description, and content.
+class _NodeDetailSection extends StatelessWidget {
+ const _NodeDetailSection({
+ required this.title,
+ required this.description,
+ required this.child,
+ });
+
+ final String title;
+ final String description;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: theme.boldTextStyle),
+ const SizedBox(height: densePadding),
+ Text(description, style: theme.subtleTextStyle),
+ const SizedBox(height: denseSpacing),
+ child,
+ ],
+ );
+ }
+}
+
+/// A bordered container that displays a property value or `(empty)` if none is present.
+class _NodeDetailValueBox extends StatelessWidget {
+ const _NodeDetailValueBox({
+ required this.text,
+ this.highlightText = false,
+ this.wrapInQuotes = false,
+ });
+
+ final String? text;
+ final bool highlightText;
+ final bool wrapInQuotes;
+
+ static const _emptyPlaceholder = '(empty)';
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final hasValue = text != null && text!.isNotEmpty;
+ final displayText = hasValue
+ ? (wrapInQuotes ? '"$text"' : text!)
+ : _emptyPlaceholder;
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(defaultSpacing),
+ decoration: BoxDecoration(
+ color: colorScheme.onSurface.withValues(alpha: 0.05),
+ borderRadius: defaultBorderRadius,
+ border: Border.all(
+ color: highlightText && hasValue
+ ? colorScheme.primary.withValues(alpha: 0.5)
+ : theme.focusColor,
+ ),
+ ),
+ child: SelectableText(
+ displayText,
+ style: _textStyle(theme, hasValue: hasValue),
+ ),
+ );
+ }
+
+ TextStyle _textStyle(ThemeData theme, {required bool hasValue}) {
+ if (!hasValue) {
+ return theme.subtleFixedFontStyle;
+ }
+ if (highlightText) {
+ return theme.fixedFontStyle.copyWith(
+ color: theme.colorScheme.primary,
+ fontWeight: FontWeight.bold,
+ );
+ }
+ return theme.fixedFontStyle;
+ }
+}
+
+/// A wrapping layout of chips representing active [SemanticsFlag]s on a node.
+class _SemanticsFlagsWrap extends StatelessWidget {
+ const _SemanticsFlagsWrap({required this.flags});
+
+ final Set<SemanticsFlag> flags;
+
+ @override
+ Widget build(BuildContext context) {
+ return Wrap(
+ spacing: denseSpacing,
+ runSpacing: denseSpacing,
+ children: [for (final flag in flags) _SemanticsFlagChip(flag: flag)],
+ );
+ }
+}
+
+/// A chip widget displaying the name of a single [SemanticsFlag].
+class _SemanticsFlagChip extends StatelessWidget {
+ const _SemanticsFlagChip({required this.flag});
+
+ final SemanticsFlag flag;
+
+ static const _chipBorderRadius = 4.0;
+ static const _chipBackgroundAlpha = 0.12;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: denseSpacing,
+ vertical: densePadding,
+ ),
+ decoration: BoxDecoration(
+ color: colorScheme.onSurface.withValues(alpha: _chipBackgroundAlpha),
+ borderRadius: BorderRadius.circular(_chipBorderRadius),
+ ),
+ child: Text(flag.name, style: theme.fixedFontStyle),
+ );
+ }
+}
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
index 2ee7209..f03fe12 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart
@@ -5,7 +5,7 @@
/// @docImport 'package:flutter/semantics.dart';
library;
-import 'dart:ui' show SemanticsFlag;
+import 'dart:ui' show Rect, SemanticsFlag;
import '../../shared/primitives/trees.dart';
@@ -14,6 +14,9 @@
SemanticsNodeModel({
required this.id,
this.label = '',
+ this.value = '',
+ this.hint = '',
+ this.rect,
this.flags = const <SemanticsFlag>{},
this.widgetName = '',
});
@@ -24,6 +27,15 @@
/// The user-visible label announced by screen readers (maps to [SemanticsData.label]).
final String label;
+ /// The textual description of the value of the node (maps to [SemanticsData.value]).
+ final String value;
+
+ /// A brief description of the result of performing an action on the node (maps to [SemanticsData.hint]).
+ final String hint;
+
+ /// The bounding box of the node in logical pixels (maps to [SemanticsNode.rect]).
+ final Rect? rect;
+
/// Semantic flags active on this node.
final Set<SemanticsFlag> flags;
@@ -44,11 +56,56 @@
.toSet();
}
+ /// Parses a JSON representation of a bounding box into a [Rect].
+ static Rect? parseRect(Object? rawRect) {
+ if (rawRect is! Map) return null;
+ final left = (rawRect['left'] as num?)?.toDouble();
+ final top = (rawRect['top'] as num?)?.toDouble();
+ if (left == null || top == null) return null;
+
+ if (rawRect.containsKey('width') && rawRect.containsKey('height')) {
+ final width = (rawRect['width'] as num?)?.toDouble();
+ final height = (rawRect['height'] as num?)?.toDouble();
+ if (width != null && height != null) {
+ return Rect.fromLTWH(left, top, width, height);
+ }
+ }
+ if (rawRect.containsKey('right') && rawRect.containsKey('bottom')) {
+ final right = (rawRect['right'] as num?)?.toDouble();
+ final bottom = (rawRect['bottom'] as num?)?.toDouble();
+ if (right != null && bottom != null) {
+ return Rect.fromLTRB(left, top, right, bottom);
+ }
+ }
+ return null;
+ }
+
+ /// Formatted string representation of [rect] in `Rect.fromLTWH` syntax.
+ String? get rectDisplay {
+ final currentRect = rect;
+ if (currentRect == null) return null;
+ final left = _formatNumber(currentRect.left);
+ final top = _formatNumber(currentRect.top);
+ final width = _formatNumber(currentRect.width);
+ final height = _formatNumber(currentRect.height);
+ return 'rect: Rect.fromLTWH($left, $top, $width, $height)';
+ }
+
+ static String _formatNumber(double value) {
+ if (value == value.truncateToDouble()) {
+ return value.toInt().toString();
+ }
+ return value.toStringAsFixed(1);
+ }
+
@override
SemanticsNodeModel shallowCopy() {
return SemanticsNodeModel(
id: id,
label: label,
+ value: value,
+ hint: hint,
+ rect: rect,
flags: flags,
widgetName: widgetName,
);
diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
index 4e58065..3974b15 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart
@@ -167,67 +167,74 @@
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
- return TreeView<SemanticsNodeModel>(
- dataRootsListenable: controller.semanticsRoots,
- scrollController: controller.treeScrollController,
- includeScrollbar: true,
- dataDisplayProvider: (node, onPressed) {
- return InkWell(
- onTap: onPressed,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: denseSpacing),
- child: Row(
- children: [
- Icon(
- _iconForNode(node),
- size: defaultIconSize,
- color: colorScheme.onSurface.withValues(alpha: 0.7),
- ),
- const SizedBox(width: denseSpacing),
- Text(
- 'SemanticsNode #${node.id}',
- maxLines: 1,
- style: theme.fixedFontStyle,
- ),
- if (node.label.isNotEmpty) ...[
- const SizedBox(width: denseSpacing),
- Flexible(
- child: Text(
- '"${node.label}"',
+ return ValueListenableBuilder<SemanticsNodeModel?>(
+ valueListenable: controller.selectedSemanticsNode,
+ builder: (context, _, _) {
+ return TreeView<SemanticsNodeModel>(
+ dataRootsListenable: controller.semanticsRoots,
+ scrollController: controller.treeScrollController,
+ includeScrollbar: true,
+ onItemSelected: controller.selectSemanticsNode,
+ onItemExpanded: (_) {},
+ dataDisplayProvider: (node, onPressed) {
+ return InkWell(
+ onTap: onPressed,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: denseSpacing),
+ child: Row(
+ children: [
+ Icon(
+ _iconForNode(node),
+ size: defaultIconSize,
+ color: colorScheme.onSurface.withValues(alpha: 0.7),
+ ),
+ const SizedBox(width: denseSpacing),
+ Text(
+ 'SemanticsNode #${node.id}',
maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.subtleTextStyle.copyWith(
- fontStyle: FontStyle.italic,
- color: colorScheme.onSurface.withValues(alpha: 0.6),
+ style: theme.fixedFontStyle,
+ ),
+ if (node.label.isNotEmpty) ...[
+ const SizedBox(width: denseSpacing),
+ Flexible(
+ child: Text(
+ '"${node.label}"',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.subtleTextStyle.copyWith(
+ fontStyle: FontStyle.italic,
+ color: colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ ),
),
- ),
- ),
- ],
- if (node.widgetName.isNotEmpty) ...[
- const SizedBox(width: denseSpacing),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: densePadding,
- ),
- decoration: BoxDecoration(
- color: colorScheme.primaryContainer.withValues(
- alpha: 0.2,
+ ],
+ if (node.widgetName.isNotEmpty) ...[
+ const SizedBox(width: denseSpacing),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: densePadding,
+ ),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer.withValues(
+ alpha: 0.2,
+ ),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ node.widgetName,
+ maxLines: 1,
+ style: theme.subtleTextStyle.copyWith(
+ color: colorScheme.primary,
+ fontSize: smallFontSize,
+ ),
+ ),
),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(
- node.widgetName,
- maxLines: 1,
- style: theme.subtleTextStyle.copyWith(
- color: colorScheme.primary,
- fontSize: smallFontSize,
- ),
- ),
- ),
- ],
- ],
- ),
- ),
+ ],
+ ],
+ ),
+ ),
+ );
+ },
);
},
);
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
index 06f3eb8..a39b9f5 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
@@ -5,7 +5,7 @@
@TestOn('vm')
library;
-import 'dart:ui' show SemanticsFlag;
+import 'dart:ui' show Rect, SemanticsFlag;
import 'package:devtools_app/devtools_app.dart';
import 'package:devtools_app/src/service/service_registrations.dart'
@@ -144,33 +144,66 @@
},
);
- test('SemanticsNodeModel properties and shallowCopy', () {
- final child = SemanticsNodeModel(
- id: '1',
- label: 'Child Node',
- flags: {SemanticsFlag.isButton, SemanticsFlag.hasCheckedState},
- widgetName: 'ElevatedButton',
- );
- final parent = SemanticsNodeModel(
- id: '0',
- label: 'Parent Node',
- flags: {SemanticsFlag.isHeader},
- widgetName: 'Column',
- )..addChild(child);
+ test(
+ 'SemanticsNodeModel properties, parseRect, rectDisplay, and shallowCopy',
+ () {
+ const expectedRect = Rect.fromLTWH(0, 0, 763, 32);
+ final child = SemanticsNodeModel(
+ id: '1',
+ label: 'Child Node',
+ value: 'Child Value',
+ hint: 'Child Hint',
+ rect: expectedRect,
+ flags: {SemanticsFlag.isButton, SemanticsFlag.hasCheckedState},
+ widgetName: 'ElevatedButton',
+ );
+ final parent = SemanticsNodeModel(
+ id: '0',
+ label: 'Parent Node',
+ flags: {SemanticsFlag.isHeader},
+ widgetName: 'Column',
+ )..addChild(child);
- expect(parent.children, hasLength(1));
- expect(parent.children.first.id, equals('1'));
+ expect(parent.children, hasLength(1));
+ expect(parent.children.first.id, equals('1'));
+ expect(parent.rectDisplay, isNull);
+ expect(child.rectDisplay, equals('rect: Rect.fromLTWH(0, 0, 763, 32)'));
- final copy = child.shallowCopy();
- expect(copy.id, equals('1'));
- expect(copy.label, equals('Child Node'));
- expect(
- copy.flags,
- equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
- );
- expect(copy.widgetName, equals('ElevatedButton'));
- expect(copy.children, isEmpty);
- });
+ final copy = child.shallowCopy();
+ expect(copy.id, equals('1'));
+ expect(copy.label, equals('Child Node'));
+ expect(copy.value, equals('Child Value'));
+ expect(copy.hint, equals('Child Hint'));
+ expect(copy.rect, equals(expectedRect));
+ expect(
+ copy.flags,
+ equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
+ );
+ expect(copy.widgetName, equals('ElevatedButton'));
+ expect(copy.children, isEmpty);
+
+ expect(
+ SemanticsNodeModel.parseRect({
+ 'left': 10,
+ 'top': 20,
+ 'width': 100,
+ 'height': 50,
+ }),
+ equals(const Rect.fromLTWH(10, 20, 100, 50)),
+ );
+ expect(
+ SemanticsNodeModel.parseRect({
+ 'left': 10,
+ 'top': 20,
+ 'right': 110,
+ 'bottom': 70,
+ }),
+ equals(const Rect.fromLTRB(10, 20, 110, 70)),
+ );
+ expect(SemanticsNodeModel.parseRect(null), isNull);
+ expect(SemanticsNodeModel.parseRect('invalid'), isNull);
+ },
+ );
test(
'loadSemanticsTree sets error state when no main isolate connected',
@@ -187,6 +220,7 @@
);
expect(testController.semanticsTreeLoading.value, isFalse);
expect(testController.semanticsRoots.value, isEmpty);
+ expect(testController.selectedSemanticsNode.value, isNull);
},
);
@@ -214,11 +248,12 @@
),
);
expect(testController.semanticsRoots.value, isEmpty);
+ expect(testController.selectedSemanticsNode.value, isNull);
},
);
test(
- 'loadSemanticsTree parses full SemanticsNode.toJson format with multiple nodes',
+ 'loadSemanticsTree parses full SemanticsNode.toJson format with multiple nodes and selects root',
() async {
final fakeServiceManager =
serviceConnection.serviceManager as FakeServiceManager;
@@ -318,16 +353,24 @@
final root = testController.semanticsRoots.value.first;
expect(root.id, equals('0'));
expect(root.label, equals('Root View'));
+ expect(root.value, equals('Main Screen'));
+ expect(root.hint, isEmpty);
+ expect(root.rect, equals(const Rect.fromLTWH(0, 0, 390, 844)));
expect(
root.flags,
equals({SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled}),
);
expect(root.children, hasLength(2));
+ // Root node is automatically selected on load
+ expect(testController.selectedSemanticsNode.value, equals(root));
+ expect(root.isSelected, isTrue);
+
// Node 1: Settings Header
final headerNode = root.children[0];
expect(headerNode.id, equals('1'));
expect(headerNode.label, equals('Settings Header'));
+ expect(headerNode.rect, equals(const Rect.fromLTWH(16, 40, 358, 32)));
expect(headerNode.flags, equals({SemanticsFlag.isHeader}));
expect(headerNode.children, isEmpty);
@@ -335,6 +378,9 @@
final searchNode = root.children[1];
expect(searchNode.id, equals('2'));
expect(searchNode.label, equals('Search Input'));
+ expect(searchNode.value, equals('Flutter'));
+ expect(searchNode.hint, equals('Enter search query'));
+ expect(searchNode.rect, equals(const Rect.fromLTWH(16, 88, 358, 48)));
expect(searchNode.flags, equals({SemanticsFlag.isTextField}));
expect(searchNode.children, hasLength(1));
@@ -343,10 +389,23 @@
expect(clearButtonNode.id, equals('3'));
expect(clearButtonNode.label, equals('Clear Text'));
expect(
+ clearButtonNode.rect,
+ equals(const Rect.fromLTWH(330, 96, 32, 32)),
+ );
+ expect(
clearButtonNode.flags,
equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}),
);
expect(clearButtonNode.children, isEmpty);
+
+ // Selecting another node updates selectedSemanticsNode and isSelected state
+ testController.selectSemanticsNode(clearButtonNode);
+ expect(
+ testController.selectedSemanticsNode.value,
+ equals(clearButtonNode),
+ );
+ expect(clearButtonNode.isSelected, isTrue);
+ expect(root.isSelected, isFalse);
},
);
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
index 37ecbb3..e13b494 100644
--- a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart
@@ -72,11 +72,14 @@
expect(find.byType(AccessibilityScreenBody), findsOneWidget);
expect(find.byType(SplitPane), findsAtLeastNWidgets(1));
- // Overrides pane should be visible
- expect(find.byType(AccessibilityOverridesPane), findsOneWidget);
-
// Semantics Tree pane should be visible
expect(find.byType(AccessibilitySemanticsTreePane), findsOneWidget);
+
+ // Semantics Node Details pane should be visible
+ expect(find.byType(SemanticsNodeDetailsPane), findsOneWidget);
+
+ // Overrides pane should be visible
+ expect(find.byType(AccessibilityOverridesPane), findsOneWidget);
});
testWidgetsWithWindowSize(
@@ -245,9 +248,27 @@
await pumpAccessibilityScreen(tester);
await tester.pumpAndSettle();
- expect(find.text('SemanticsNode #0'), findsOneWidget);
- expect(find.text('SemanticsNode #1'), findsOneWidget);
- expect(find.text('"Child Node"'), findsOneWidget);
+ expect(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.text('SemanticsNode #0'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.text('SemanticsNode #1'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.text('"Child Node"'),
+ ),
+ findsOneWidget,
+ );
expect(find.text('ElevatedButton'), findsOneWidget);
},
);
@@ -306,5 +327,189 @@
expect(find.byIcon(Icons.check_box_outlined), findsOneWidget);
},
);
+
+ testWidgetsWithWindowSize(
+ 'renders node details placeholder when no node is selected',
+ windowSize,
+ (WidgetTester tester) async {
+ controller.semanticsRoots.value = [];
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Semantics Node Details'), findsOneWidget);
+ expect(
+ find.text('Select a node in the semantics tree to view its details.'),
+ findsOneWidget,
+ );
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'renders root node details automatically and updates when a child node is selected',
+ windowSize,
+ (WidgetTester tester) async {
+ final childNode = SemanticsNodeModel(
+ id: '26',
+ label: 'CloseButton',
+ rect: const Rect.fromLTWH(0, 0, 763, 32),
+ flags: {
+ SemanticsFlag.isButton,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isFocusable,
+ },
+ widgetName: 'CloseButton',
+ );
+ final rootNode = SemanticsNodeModel(
+ id: '0',
+ label: 'Root Node',
+ value: 'Initial Value',
+ hint: 'Tap to interact',
+ rect: const Rect.fromLTWH(0, 0, 1000, 1000),
+ flags: {SemanticsFlag.isHeader},
+ )..addChild(childNode);
+
+ rootNode.expandCascading();
+ controller.semanticsRoots.value = [rootNode];
+
+ await pumpAccessibilityScreen(tester);
+ await tester.pumpAndSettle();
+
+ final detailsPane = find.byType(SemanticsNodeDetailsPane);
+
+ // Initially, root node (#0) is automatically selected
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('SemanticsNode #0'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(of: detailsPane, matching: find.text('"Root Node"')),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('"Initial Value"'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('"Tap to interact"'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('rect: Rect.fromLTWH(0, 0, 1000, 1000)'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(of: detailsPane, matching: find.text('isHeader')),
+ findsOneWidget,
+ );
+
+ // Tap child node (#26) in the tree pane
+ await tester.tap(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.text('SemanticsNode #26'),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ // Details pane should now display SemanticsNode #26 details
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('SemanticsNode #26'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('"CloseButton"'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('rect: Rect.fromLTWH(0, 0, 763, 32)'),
+ ),
+ findsOneWidget,
+ );
+ // Empty value and hint should render as (empty)
+ expect(
+ find.descendant(of: detailsPane, matching: find.text('(empty)')),
+ findsNWidgets(2),
+ );
+ // Flags chips
+ expect(
+ find.descendant(of: detailsPane, matching: find.text('isButton')),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(
+ of: detailsPane,
+ matching: find.text('hasEnabledState'),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(of: detailsPane, matching: find.text('isEnabled')),
+ findsOneWidget,
+ );
+ expect(
+ find.descendant(of: detailsPane, matching: find.text('isFocusable')),
+ findsOneWidget,
+ );
+
+ // Tap parent node (#0) in the tree pane to select it; its child (#26)
+ // should remain expanded and visible in the tree.
+ await tester.tap(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.text('SemanticsNode #0'),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(controller.selectedSemanticsNode.value, equals(rootNode));
+ expect(rootNode.isExpanded, isTrue);
+ expect(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.text('SemanticsNode #26'),
+ ),
+ findsOneWidget,
+ );
+
+ // Select child (#26) again, then tap the expand/collapse arrow on
+ // parent (#0); it should collapse without changing the selected node.
+ controller.selectSemanticsNode(childNode);
+ await tester.pumpAndSettle();
+ expect(controller.selectedSemanticsNode.value, equals(childNode));
+
+ await tester.tap(
+ find.descendant(
+ of: find.byType(AccessibilitySemanticsTreePane),
+ matching: find.byIcon(Icons.keyboard_arrow_down),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(rootNode.isExpanded, isFalse);
+ expect(controller.selectedSemanticsNode.value, equals(childNode));
+ },
+ );
});
}