diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart new file mode 100644 index 00000000000..8039bf0b878 --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart @@ -0,0 +1,165 @@ +// Copyright 2025 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 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../../shared/ui/colors.dart'; + +class HowToUseMessage extends StatelessWidget { + const HowToUseMessage({super.key}); + + static const _lightHighlighterColor = Colors.yellow; + static const _darkHighlighterColor = Color.fromARGB(168, 191, 17, 196); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final fixedFontStyle = theme.fixedFontStyle; + TextSpan colorA(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.declarationsSyntaxColor, + ); + TextSpan colorB(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.modifierSyntaxColor, + ); + TextSpan colorC(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.variableSyntaxColor, + ); + TextSpan colorD(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.controlFlowSyntaxColor, + ); + TextSpan colorE(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.stringSyntaxColor, + ); + TextSpan colorF(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.functionSyntaxColor, + ); + TextSpan colorG(String text) => _coloredSpan( + text, + style: fixedFontStyle, + color: colorScheme.numericConstantSyntaxColor, + ); + TextSpan highlight(TextSpan original) => _highlight( + original, + highlighterColor: + theme.isDarkTheme ? _darkHighlighterColor : _lightHighlighterColor, + ); + + return Text.rich( + TextSpan( + children: [ + const TextSpan(text: '\nPlease move your cursor anywhere inside a '), + TextSpan( + text: 'Flutter widget constructor invocation', + style: theme.boldTextStyle, + ), + const TextSpan(text: ' to view and edit its properties.\n\n'), + const TextSpan( + text: + 'For example, the highlighted code below is a constructor invocation of a ', + ), + TextSpan( + text: 'Text', + style: Theme.of( + context, + ).fixedFontStyle.copyWith(color: colorScheme.primary), + ), + const TextSpan(text: ' widget:\n\n'), + colorA('@override\n'), + colorB('Widget '), + colorG('build'), + colorC('('), + colorB('BuildContext '), + colorA('context'), + colorC(') '), + colorC('{\n'), + colorD(' return '), + highlight(colorB('Text')), + highlight(colorC('(\n')), + highlight(colorE(' "Hello World!"')), + highlight(colorF(',\n')), + highlight(colorA(' overflow')), + highlight(colorF(': ')), + highlight(colorB('TextOveflow')), + highlight(colorF('.')), + highlight(colorG('clip')), + highlight(colorF(',\n')), + highlight(colorC(' )')), + highlight(colorF(';\n')), + colorC('}'), + ], + ), + ); + } + + TextSpan _coloredSpan( + String text, { + required TextStyle style, + required Color color, + }) => TextSpan(text: text, style: style.copyWith(color: color)); + + TextSpan _highlight(TextSpan original, {required Color highlighterColor}) => + TextSpan( + text: original.text, + style: original.style!.copyWith(backgroundColor: highlighterColor), + ); +} + +class NoDartCodeMessage extends StatelessWidget { + const NoDartCodeMessage({super.key}); + + @override + Widget build(BuildContext context) { + return Text( + 'No Dart code found at the current cursor location.', + style: Theme.of(context).textTheme.bodyLarge, + ); + } +} + +class NoMatchingPropertiesMessage extends StatelessWidget { + const NoMatchingPropertiesMessage({super.key}); + + @override + Widget build(BuildContext context) { + return const Text('No properties matching the current filter.'); + } +} + +class NoWidgetAtLocationMessage extends StatelessWidget { + const NoWidgetAtLocationMessage({super.key}); + + @override + Widget build(BuildContext context) { + return Text( + 'No Flutter widget found at the current cursor location.', + style: Theme.of(context).textTheme.bodyLarge, + ); + } +} + +class WelcomeMessage extends StatelessWidget { + const WelcomeMessage({super.key}); + + @override + Widget build(BuildContext context) { + return Text( + '👋 Welcome to the Flutter Property Editor!', + style: Theme.of(context).textTheme.bodyLarge, + ); + } +} diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart index 3fe5512d9b2..aab7b0d8e96 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart @@ -11,6 +11,7 @@ import '../../../shared/ui/common_widgets.dart'; import '../../../shared/ui/filter.dart'; import 'property_editor_controller.dart'; import 'property_editor_inputs.dart'; +import 'property_editor_messages.dart'; import 'property_editor_types.dart'; import 'utils/utils.dart'; @@ -26,7 +27,6 @@ class PropertyEditorView extends StatelessWidget { controller.editorClient.editArgumentMethodName, controller.editorClient.editableArgumentsMethodName, controller.editableWidgetData, - controller.filteredData, ], builder: (_, values, _) { final editArgumentMethodName = values.first as String?; @@ -38,56 +38,56 @@ class PropertyEditorView extends StatelessWidget { } final editableWidgetData = values.third as EditableWidgetData?; - if (editableWidgetData == null) { - final introSentence = - controller.waitingForFirstEvent - ? '👋 Welcome to the Flutter Property Editor!' - : 'No Flutter widget found at the current cursor location.'; - const howToUseSentence = - 'Please move your cursor to a Flutter widget constructor invocation to view its properties.'; - return CenteredMessage( - message: '$introSentence\n\n$howToUseSentence', - ); - } - - final fileUri = controller.fileUri; - if (fileUri != null && !fileUri.endsWith('.dart')) { - return const CenteredMessage( - message: 'No Dart code found at the current cursor location.', - ); - } - - final filteredProperties = values.fourth as List; - final widgetName = controller.widgetName; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widgetName != null) - _WidgetNameAndDocumentation( - name: widgetName, - documentation: controller.widgetDocumentation, - ), - controller.allProperties.isEmpty - ? _NoEditablePropertiesMessage(name: controller.widgetName) - : _PropertiesList( - controller: controller, - editableProperties: filteredProperties, - ), - ], + return SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _propertyEditorContents(editableWidgetData), + ), ); }, ); } + + List _propertyEditorContents(EditableWidgetData? editableWidgetData) { + if (editableWidgetData == null) { + final introSentence = + controller.waitingForFirstEvent + ? const WelcomeMessage() + : const NoWidgetAtLocationMessage(); + return [introSentence, const HowToUseMessage()]; + } + + final (:properties, :name, :documentation, :fileUri) = editableWidgetData; + if (fileUri != null && !fileUri.endsWith('.dart')) { + return [const NoDartCodeMessage(), const HowToUseMessage()]; + } + + final contents = []; + if (name != null) { + contents.add( + _WidgetNameAndDocumentation(name: name, documentation: documentation), + ); + } + if (properties.isEmpty) { + if (name != null) { + contents.add(_NoEditablePropertiesMessage(name: name)); + } else { + contents.addAll([ + const NoWidgetAtLocationMessage(), + const HowToUseMessage(), + ]); + } + } else { + contents.add(_PropertiesList(controller: controller)); + } + return contents; + } } class _PropertiesList extends StatefulWidget { - const _PropertiesList({ - required this.controller, - required this.editableProperties, - }); + const _PropertiesList({required this.controller}); final PropertyEditorController controller; - final List editableProperties; static const defaultItemPadding = borderPadding; static const denseItemPadding = defaultItemPadding / 2; @@ -113,18 +113,22 @@ class _PropertiesListState extends State<_PropertiesList> { @override Widget build(BuildContext context) { - return Column( - children: [ - _FilterControls(controller: widget.controller), - if (widget.editableProperties.isEmpty) - const _NoMatchingPropertiesMessage(), - for (final property in widget.editableProperties) - _EditablePropertyItem( - property: property, - editProperty: widget.controller.editArgument, - widgetDocumentation: widget.controller.widgetDocumentation, - ), - ].joinWith(const PaddedDivider.noPadding()), + return ValueListenableBuilder( + valueListenable: widget.controller.filteredData, + builder: (context, properties, _) { + return Column( + children: [ + _FilterControls(controller: widget.controller), + if (properties.isEmpty) const NoMatchingPropertiesMessage(), + for (final property in properties) + _EditablePropertyItem( + property: property, + editProperty: widget.controller.editArgument, + widgetDocumentation: widget.controller.widgetDocumentation, + ), + ].joinWith(const PaddedDivider.noPadding()), + ); + }, ); } } @@ -436,15 +440,6 @@ class _NoEditablePropertiesMessage extends StatelessWidget { } } -class _NoMatchingPropertiesMessage extends StatelessWidget { - const _NoMatchingPropertiesMessage(); - - @override - Widget build(BuildContext context) { - return const Text('No properties matching the current filter.'); - } -} - class _WidgetNameAndDocumentation extends StatelessWidget { const _WidgetNameAndDocumentation({required this.name, this.documentation}); @@ -453,34 +448,32 @@ class _WidgetNameAndDocumentation extends StatelessWidget { @override Widget build(BuildContext context) { - return SelectionArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(bottom: denseSpacing), - child: Text( - name, - style: Theme.of(context).fixedFontStyle.copyWith( - fontWeight: FontWeight.bold, - fontSize: defaultFontSize + 1, - ), + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(bottom: denseSpacing), + child: Text( + name, + style: Theme.of(context).fixedFontStyle.copyWith( + fontWeight: FontWeight.bold, + fontSize: defaultFontSize + 1, ), ), - Row( - children: [ - Expanded( - child: _ExpandableWidgetDocumentation( - documentation: - documentation ?? 'Creates ${addIndefiniteArticle(name)}.', - ), + ), + Row( + children: [ + Expanded( + child: _ExpandableWidgetDocumentation( + documentation: + documentation ?? 'Creates ${addIndefiniteArticle(name)}.', ), - ], - ), - const PaddedDivider.noPadding(), - ], - ), + ), + ], + ), + const PaddedDivider.noPadding(), + ], ); } } diff --git a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart index f9caa98afa4..62702969685 100644 --- a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart +++ b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart @@ -114,6 +114,20 @@ void main() { } }); + testWidgets('initial welcome screen', (tester) async { + // Load the property editor. + await tester.pumpWidget(wrap(propertyEditor)); + + // Change the editable args. + controller.initForTestsOnly(); + await tester.pumpAndSettle(); + + // Verify the welcome message is shown. + expect(find.textContaining(welcomeMessageText), findsOneWidget); + expect(find.textContaining(howToUseText), findsOneWidget); + expect(find.textContaining(exampleWidgetText), findsOneWidget); + }); + testWidgets('verify editable arguments for first cursor location', ( tester, ) async { @@ -229,12 +243,9 @@ void main() { // Verify "No Dart code" message is shown. await tester.pumpAndSettle(); - expect( - find.textContaining( - 'No Dart code found at the current cursor location.', - ), - findsOneWidget, - ); + expect(find.textContaining(noDartCodeText), findsOneWidget); + expect(find.textContaining(howToUseText), findsOneWidget); + expect(find.textContaining(exampleWidgetText), findsOneWidget); }); }); }); @@ -385,6 +396,22 @@ void main() { tester: tester, ); }); + + testWidgets('no inputs if widget has no editable properties', ( + tester, + ) async { + // Load the property editor. + await tester.pumpWidget(wrap(propertyEditor)); + + // Change the editable args. + controller.initForTestsOnly(editableArgsResult: resultWithNoWidget); + await tester.pumpAndSettle(); + + // Verify the "No widget" message is displayed. + expect(find.textContaining(noWidgetText), findsOneWidget); + expect(find.textContaining(howToUseText), findsOneWidget); + expect(find.textContaining(exampleWidgetText), findsOneWidget); + }); }); group('inputs for deprecated arguments', () { @@ -1415,3 +1442,12 @@ final resultWithTitle = EditableArgumentsResult( name: 'WidgetWithTitle', args: [titleProperty], ); +final resultWithNoWidget = EditableArgumentsResult(args: []); + +const welcomeMessageText = 'Welcome to the Flutter Property Editor!'; +const howToUseText = + 'Please move your cursor anywhere inside a Flutter widget constructor invocation to view and edit its properties.'; +const exampleWidgetText = + 'For example, the highlighted code below is a constructor invocation of a Text widget:'; +const noDartCodeText = 'No Dart code found at the current cursor location.'; +const noWidgetText = 'No Flutter widget found at the current cursor location.';