Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/devtools_app/lib/devtools_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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_model.dart';
export 'src/screens/app_size/app_size_controller.dart';
export 'src/screens/app_size/app_size_screen.dart';
export 'src/screens/debugger/breakpoint_manager.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import 'dart:async';
import 'package:devtools_app_shared/service.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show ScrollController;
import 'package:logging/logging.dart';

import '../../service/service_extensions.dart' as extensions;
import '../../service/service_registrations.dart' as registrations;
import '../../shared/framework/screen.dart';
import '../../shared/framework/screen_controllers.dart';
import '../../shared/globals.dart';
import 'semantics_node_model.dart';

final _log = Logger('accessibility_controller');

/// Modes for brightness override in the accessibility controls.
enum BrightnessOverride {
Expand Down Expand Up @@ -40,6 +46,7 @@ class AccessibilityController extends DevToolsScreenController
void init() {
super.init();
_initServiceExtensionStates();
_initSemanticsTree();
}

void _initListeners() {
Expand All @@ -50,6 +57,37 @@ class AccessibilityController extends DevToolsScreenController
addAutoDisposeListener(highContrast, _onHighContrastChanged);
}

void _initSemanticsTree() {
if (serviceConnection.serviceManager.isolateManager.mainIsolate.value !=
null) {
unawaited(_autoLoadSemanticsTreeIfNeeded());
}
addAutoDisposeListener(
serviceConnection.serviceManager.isolateManager.mainIsolate,
() {
if (serviceConnection.serviceManager.isolateManager.mainIsolate.value !=
null) {
// Clear stale data from a previous isolate so the guard in
// _autoLoadSemanticsTreeIfNeeded doesn't skip the new load.
semanticsRoots.value = [];
semanticsTreeError.value = null;
unawaited(_autoLoadSemanticsTreeIfNeeded());
} else {
semanticsRoots.value = [];
semanticsTreeError.value = null;
}
},
);
Comment thread
hannah-hyj marked this conversation as resolved.
}

Future<void> _autoLoadSemanticsTreeIfNeeded() async {
if (semanticsRoots.value.isEmpty &&
semanticsTreeError.value == null &&
!semanticsTreeLoading.value) {
await loadSemanticsTree();
}
}

void _initServiceExtensionStates() {
final state = serviceConnection.serviceManager.serviceExtensionManager
.getServiceExtensionState(extensions.brightnessMode.extension);
Expand Down Expand Up @@ -109,13 +147,144 @@ class AccessibilityController extends DevToolsScreenController
final screenReader = ValueNotifier<bool>(false);
final highContrast = ValueNotifier<bool>(false);

final semanticsRoots = ValueNotifier<List<SemanticsNodeModel>>([]);
final semanticsTreeLoading = ValueNotifier<bool>(false);
final semanticsTreeError = ValueNotifier<String?>(null);
final treeScrollController = ScrollController();

Future<void> loadSemanticsTree() async {
if (semanticsTreeLoading.value) return;

final mainIsolate =
serviceConnection.serviceManager.isolateManager.mainIsolate.value;
if (mainIsolate == null) {
semanticsTreeError.value =
'Failed to load semantics tree: no connected application.';
return;
}

semanticsTreeLoading.value = true;
semanticsTreeError.value = null;

try {
await serviceConnection.serviceManager.callServiceExtensionOnMainIsolate(
registrations.enableSemantics,
args: {'enabled': 'true'},
);

final response = await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(registrations.getSemanticsTree);

final json = response.json;
if (json != null && json.containsKey('error')) {
throw Exception(json['error']);
}

final rawData = json?['data'];
if (rawData == null) {
throw Exception(
'Empty semantics tree returned from service extension.',
);
}

final roots = <SemanticsNodeModel>[];
if (rawData is Map<String, dynamic>) {
if (rawData.isNotEmpty) {
final rootId = rawData.containsKey('0')
? '0'
: rawData.keys.first.toString();
roots.add(_buildTreeFromNodesMap(rootId, rawData, <String>{}));
}
}

if (roots.isEmpty) {
throw Exception('No semantics nodes found in response.');
}

for (final root in roots) {
root.expandCascading();
}
semanticsRoots.value = roots;
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';
} finally {
if (!disposed) {
semanticsTreeLoading.value = false;
}
}
}

SemanticsNodeModel _buildTreeFromNodesMap(
String nodeId,
Map<String, dynamic> nodesMap,
Set<String> visited,
) {
if (!visited.add(nodeId)) {
return SemanticsNodeModel(id: nodeId);
}

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

final childIds =
(json['childrenInTraversalOrder'] as List?)
?.map((e) => e.toString())
.toList() ??
(json['childrenInHitTestOrder'] as List?)
?.map((e) => e.toString())
.toList() ??
const <String>[];

for (final childId in childIds) {
if (nodesMap.containsKey(childId)) {
final childNode = _buildTreeFromNodesMap(childId, nodesMap, visited);
node.addChild(childNode);
}
}

return node;
}

SemanticsNodeModel _parseSemanticsNode(Map<String, dynamic> json) {
final rawFlags = json['flags'] as List<Object?>?;
final flags = SemanticsNodeModel.parseFlags(rawFlags);

return SemanticsNodeModel(
id: json['id']?.toString() ?? '',
label: json['label']?.toString() ?? '',
flags: flags,
widgetName: json['widgetName']?.toString() ?? '',
);
}
Comment thread
hannah-hyj marked this conversation as resolved.

@override
void dispose() {
unawaited(_disposeSemanticsOnApp());
brightness.dispose();
textScale.dispose();
boldText.dispose();
screenReader.dispose();
highContrast.dispose();
semanticsRoots.dispose();
semanticsTreeLoading.dispose();
semanticsTreeError.dispose();
treeScrollController.dispose();
super.dispose();
}

Future<void> _disposeSemanticsOnApp() async {
try {
if (serviceConnection.serviceManager.connectedState.value.connected) {
await serviceConnection.serviceManager
.callServiceExtensionOnMainIsolate(registrations.disposeSemantics);
}
} catch (_) {
// Ignore errors if the app or isolate connection is already closed.
}
}
Comment thread
hannah-hyj marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 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.

/// @docImport 'package:flutter/semantics.dart';
library;

import 'dart:ui' show SemanticsFlag;

import '../../shared/primitives/trees.dart';

/// Represents a node in the accessibility semantics tree.
class SemanticsNodeModel extends TreeNode<SemanticsNodeModel> {
SemanticsNodeModel({
required this.id,
this.label = '',
this.flags = const <SemanticsFlag>{},
this.widgetName = '',
});

/// The semantics node identifier, as provided by the Flutter framework.
final String id;

/// The user-visible label announced by screen readers (maps to [SemanticsData.label]).
final String label;

/// Semantic flags active on this node.
final Set<SemanticsFlag> flags;

/// The name of the Flutter widget that produced this node, if available.
final String widgetName;

/// Mapping from flag name strings to [SemanticsFlag] instances.
static final _flagByName = <String, SemanticsFlag>{
for (final flag in SemanticsFlag.values) flag.name: flag,
};

/// Parses a list of flag name strings into a set of [SemanticsFlag]s.
static Set<SemanticsFlag> parseFlags(List<Object?>? rawFlags) {
if (rawFlags == null) return const <SemanticsFlag>{};
return rawFlags
.map((e) => _flagByName[e?.toString()])
.whereType<SemanticsFlag>()
.toSet();
}

@override
SemanticsNodeModel shallowCopy() {
return SemanticsNodeModel(
id: id,
label: label,
flags: flags,
widgetName: widgetName,
);
}
}
Loading
Loading