diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart index 5fd07a4ff1e..a2e566a65ca 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/app_bar.dart'; 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 573756a67aa..df0242350fc 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 @@ class AccessibilityController extends DevToolsScreenController addAutoDisposeListener(boldText, _onBoldTextChanged); addAutoDisposeListener(screenReader, _onScreenReaderChanged); addAutoDisposeListener(highContrast, _onHighContrastChanged); + addAutoDisposeListener(semanticsRoots, _onSemanticsRootsChanged); + } + + void _onSemanticsRootsChanged() { + selectSemanticsNode(semanticsRoots.value.firstOrNull); } void _initSemanticsTree() { @@ -148,10 +153,19 @@ class AccessibilityController extends DevToolsScreenController final highContrast = ValueNotifier(false); final semanticsRoots = ValueNotifier>([]); + final selectedSemanticsNode = ValueNotifier(null); final semanticsTreeLoading = ValueNotifier(false); final semanticsTreeError = ValueNotifier(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 loadSemanticsTree() async { if (semanticsTreeLoading.value) return; @@ -175,6 +189,8 @@ class AccessibilityController extends DevToolsScreenController 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 @@ class AccessibilityController extends DevToolsScreenController 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 @@ class AccessibilityController extends DevToolsScreenController SemanticsNodeModel _parseSemanticsNode(Map json) { final rawFlags = json['flags'] as List?; 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 @@ class AccessibilityController extends DevToolsScreenController 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 f32e71e97a8..89e47cc6174 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/framework/screen.dart'; 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 @@ class _AccessibilityScreenBodyState extends State // 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 @@ class _AccessibilityScreenBodyState extends State 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 00000000000..7202781b4b3 --- /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(); + return DevToolsAreaPane( + header: const AreaPaneHeader( + title: Text(_paneTitle), + roundedTopBorder: false, + includeTopBorder: false, + ), + child: ValueListenableBuilder( + 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 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 2ee7209adc9..f03fe1245b6 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 @@ class SemanticsNodeModel extends TreeNode { SemanticsNodeModel({ required this.id, this.label = '', + this.value = '', + this.hint = '', + this.rect, this.flags = const {}, this.widgetName = '', }); @@ -24,6 +27,15 @@ class SemanticsNodeModel extends TreeNode { /// 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 flags; @@ -44,11 +56,56 @@ class SemanticsNodeModel extends TreeNode { .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 4e580650038..3974b15460e 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 @@ class _SemanticsTreeContent extends StatelessWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - return TreeView( - 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}"', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.subtleTextStyle.copyWith( - fontStyle: FontStyle.italic, - color: colorScheme.onSurface.withValues(alpha: 0.6), - ), + return ValueListenableBuilder( + valueListenable: controller.selectedSemanticsNode, + builder: (context, _, _) { + return TreeView( + 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), ), - ), - ], - if (node.widgetName.isNotEmpty) ...[ - const SizedBox(width: denseSpacing), - Container( - padding: const EdgeInsets.symmetric( - horizontal: densePadding, + const SizedBox(width: denseSpacing), + Text( + 'SemanticsNode #${node.id}', + maxLines: 1, + style: theme.fixedFontStyle, ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues( - alpha: 0.2, + 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), + ), + ), ), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - node.widgetName, - maxLines: 1, - style: theme.subtleTextStyle.copyWith( - color: colorScheme.primary, - fontSize: smallFontSize, + ], + 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, + ), + ), ), - ), - ), - ], - ], - ), - ), + ], + ], + ), + ), + ); + }, ); }, ); 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 06f3eb8f808..a39b9f5be1c 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 @@ void main() { }, ); - 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); - - expect(parent.children, hasLength(1)); - expect(parent.children.first.id, equals('1')); - - 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); - }); + 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.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.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 @@ void main() { ); expect(testController.semanticsTreeLoading.value, isFalse); expect(testController.semanticsRoots.value, isEmpty); + expect(testController.selectedSemanticsNode.value, isNull); }, ); @@ -214,11 +248,12 @@ void main() { ), ); 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 @@ void main() { 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 @@ void main() { 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)); @@ -342,11 +388,24 @@ void main() { final clearButtonNode = searchNode.children.first; 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 37ecbb3943c..e13b4942234 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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)); + }, + ); }); } diff --git a/pubspec.lock b/pubspec.lock index 31b595daaa5..a7b5f0046ee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -883,26 +883,26 @@ packages: dependency: transitive description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: de5d145b0afff7921e5e788a880f52d7e5f3ae24068a202f6fd3b58e4ba26323 url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.32.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.14" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "80f3fb49087454e07e7e07c67578cfdd156c8c3a5227d8b3f47c7b2d019c2e93" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.20" typed_data: dependency: transitive description: