|
| 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