Skip to content

[A11y] Add semantics tree UI - #9982

Open
hannah-hyj wants to merge 2 commits into
flutter:masterfrom
hannah-hyj:a11y_tree_visualizer
Open

[A11y] Add semantics tree UI#9982
hannah-hyj wants to merge 2 commits into
flutter:masterfrom
hannah-hyj:a11y_tree_visualizer

Conversation

@hannah-hyj

Copy link
Copy Markdown
Member

List which issues are fixed by this PR.

Replace this paragraph with a description of what this PR is changing or adding, and why. If your PR is updating any UI functionality, please include include before/after screenshots and/or a gif of the UI interaction.

Pre-launch Checklist

General checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read the Flutter Style Guide recently, and have followed its advice.
  • I signed the CLA.
  • I updated/added relevant documentation (doc comments with ///).

Issues checklist

Tests checklist

  • I added new tests to check the change I am making...
  • OR there is a reason for not adding tests, which I explained in the PR description.

AI-tooling checklist

  • I did not use any AI tooling in creating this PR.
  • OR I did use AI tooling, and...
    • I read the AI contributions guidelines and agree to follow them.
    • I reviewed all AI-generated code before opening this PR.
    • I understand and am able to discuss the code in this PR.
    • I have verifed the accuracy of any AI-generated text included in the PR description.
    • I commit to verifying the accuracy of any AI-generated code or text that I upload in response to review comments.

Feature-change checklist

  • This PR does not change the DevTools UI or behavior and...
    • I added the release-notes-not-required label or left a comment requesting the label be added.
  • OR this PR does change the DevTools UI or behavior and...
    • I added an entry to packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md.
    • I included before/after screenshots and/or a GIF demo of the new UI to my PR description.
    • I ran the DevTools app locally to manually verify my changes.

build.yaml badge

If you need help, consider asking for help on Discord.

@hannah-hyj
hannah-hyj requested a review from a team as a code owner August 27, 2026 06:12
@hannah-hyj
hannah-hyj requested review from srawlins and removed request for a team and srawlins August 27, 2026 06:12

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the accessibility semantics tree in DevTools, adding the SemanticsNodeModel to represent nodes, updating AccessibilityController to load and parse the tree via service extensions, and introducing the AccessibilitySemanticsTreePane UI along with comprehensive tests. The review feedback highlights several critical issues: a recursive parsing bug in _parseSemanticsNode that leads to duplicate child nodes, the use of a non-existent disposeSemantics service extension (which should be replaced with disabling enableSemantics), and the use of an invalid isCheckable flag which should be updated to hasCheckedState.

Comment on lines +281 to +283
final json = (nodesMap[nodeId] as Map<String, dynamic>?) ??
<String, dynamic>{'id': nodeId};
final node = _parseSemanticsNode(json);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[MUST-FIX] When building the tree from a flat map of nodes, calling _parseSemanticsNode will recursively parse and add children if they are present in the children list of the JSON. Since _buildTreeFromNodesMap also manually resolves and adds children from the nodesMap, this leads to duplicate children being added to the SemanticsNodeModel (one partially parsed, one fully parsed). Adding a parseChildren parameter to _parseSemanticsNode and setting it to false in _buildTreeFromNodesMap prevents this duplication.

Suggested change
final json = (nodesMap[nodeId] as Map<String, dynamic>?) ??
<String, dynamic>{'id': nodeId};
final node = _parseSemanticsNode(json);
final json = (nodesMap[nodeId] as Map<String, dynamic>?) ??
<String, dynamic>{'id': nodeId};
final node = _parseSemanticsNode(json, parseChildren: false);

Comment on lines +307 to +333
SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
final rect = json['rect'] as Map<String, dynamic>?;
final rectString = rect != null
? 'rect: Rect.fromLTWH(${rect['left']}, ${rect['top']}, ${rect['width']}, ${rect['height']})'
: 'Rect.zero';

final flags = (json['flags'] as List?)?.cast<String>() ?? const [];

final node = SemanticsNodeModel(
id: json['id']?.toString() ?? '',
label: json['label']?.toString() ?? '',
value: json['value']?.toString() ?? '',
hint: json['hint']?.toString() ?? '',
flags: flags,
widgetName: json['widgetName']?.toString() ?? '',
rectString: rectString,
);

final children = json['children'] as List?;
if (children != null) {
for (final childJson in children.whereType<Map<String, dynamic>>()) {
final child = _parseSemanticsNode(childJson);
node.addChild(child);
}
}
return node;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[MUST-FIX] Update _parseSemanticsNode to accept an optional parseChildren parameter (defaulting to true) to allow disabling recursive child parsing when resolving children from a flat map of nodes.

  SemanticsNodeModel _parseSemanticsNode(
    Map<String, dynamic> json, {
    bool parseChildren = true,
  }) {
    final rect = json['rect'] as Map<String, dynamic>?;
    final rectString = rect != null
        ? 'rect: Rect.fromLTWH(${rect['left']}, ${rect['top']}, ${rect['width']}, ${rect['height']})'
        : 'Rect.zero';

    final flags = (json['flags'] as List?)?.cast<String>() ?? const [];

    final node = SemanticsNodeModel(
      id: json['id']?.toString() ?? '',
      label: json['label']?.toString() ?? '',
      value: json['value']?.toString() ?? '',
      hint: json['hint']?.toString() ?? '',
      flags: flags,
      widgetName: json['widgetName']?.toString() ?? '',
      rectString: rectString,
    );

    if (parseChildren) {
      final children = json['children'] as List?;
      if (children != null) {
        for (final childJson in children.whereType<Map<String, dynamic>>()) {
          final child = _parseSemanticsNode(childJson);
          node.addChild(child);
        }
      }
    }
    return node;
  }

Comment on lines +349 to +361
Future<void> _disposeSemanticsOnApp() async {
try {
if (serviceConnection.serviceManager.connectedState.value.connected) {
await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(
'ext.flutter.accessibility.disposeSemantics',
args: {'enabled': 'false'},
);
}
} catch (_) {
// Ignore errors if the app or isolate connection is already closed.
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[MUST-FIX] The service extension ext.flutter.accessibility.disposeSemantics does not exist in the Flutter framework. To disable semantics on the connected application, you should call ext.flutter.accessibility.enableSemantics with enabled: false.

Suggested change
Future<void> _disposeSemanticsOnApp() async {
try {
if (serviceConnection.serviceManager.connectedState.value.connected) {
await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(
'ext.flutter.accessibility.disposeSemantics',
args: {'enabled': 'false'},
);
}
} catch (_) {
// Ignore errors if the app or isolate connection is already closed.
}
}
Future<void> _disposeSemanticsOnApp() async {
try {
if (serviceConnection.serviceManager.connectedState.value.connected) {
await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(
'ext.flutter.accessibility.enableSemantics',
args: {'enabled': 'false'},
);
}
} catch (_) {
// Ignore errors if the app or isolate connection is already closed.
}
}

Comment on lines +138 to +145
static IconData _iconForNode(SemanticsNodeModel node) {
if (node.flags.contains('isButton')) return Icons.smart_button_rounded;
if (node.flags.contains('isTextField')) return Icons.text_fields_rounded;
if (node.flags.contains('isHeader')) return Icons.title_rounded;
if (node.flags.contains('isSlider')) return Icons.linear_scale_rounded;
if (node.flags.contains('isCheckable')) return Icons.check_box_outlined;
return Icons.widgets_outlined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] There is no isCheckable flag in Flutter's SemanticsFlag enum. To check if a semantics node is checkable, you should check for the hasCheckedState flag instead.

Suggested change
static IconData _iconForNode(SemanticsNodeModel node) {
if (node.flags.contains('isButton')) return Icons.smart_button_rounded;
if (node.flags.contains('isTextField')) return Icons.text_fields_rounded;
if (node.flags.contains('isHeader')) return Icons.title_rounded;
if (node.flags.contains('isSlider')) return Icons.linear_scale_rounded;
if (node.flags.contains('isCheckable')) return Icons.check_box_outlined;
return Icons.widgets_outlined;
}
static IconData _iconForNode(SemanticsNodeModel node) {
if (node.flags.contains('isButton')) return Icons.smart_button_rounded;
if (node.flags.contains('isTextField')) return Icons.text_fields_rounded;
if (node.flags.contains('isHeader')) return Icons.title_rounded;
if (node.flags.contains('isSlider')) return Icons.linear_scale_rounded;
if (node.flags.contains('hasCheckedState')) return Icons.check_box_outlined;
return Icons.widgets_outlined;
}

@srawlins srawlins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice tests; I haven't quite looked at everything yet, but do you want to look at gemini's feedback and give each a thumbs up or down?

/// The current value of this node (e.g. the text in a text field).
final String value;

/// Additional hint text spoken after a delay (maps to `SemanticsData.hint`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we link with [SemanticsData.hint]? If it is not imported, we can /// @docImport it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants