diff --git a/packages/devtools_app/lib/src/shared/editor/api_classes.dart b/packages/devtools_app/lib/src/shared/editor/api_classes.dart index f752e1b4ad3..fe534ead10c 100644 --- a/packages/devtools_app/lib/src/shared/editor/api_classes.dart +++ b/packages/devtools_app/lib/src/shared/editor/api_classes.dart @@ -100,6 +100,7 @@ abstract class Field { static const documentation = 'documentation'; static const emulator = 'emulator'; static const emulatorId = 'emulatorId'; + static const end = 'end'; static const ephemeral = 'ephemeral'; static const errorText = 'errorText'; static const flutterDeviceId = 'flutterDeviceId'; @@ -122,10 +123,12 @@ abstract class Field { static const platformType = 'platformType'; static const prefersDebugSession = 'prefersDebugSession'; static const projectRootPath = 'projectRootPath'; + static const range = 'range'; static const requiresDebugSession = 'requiresDebugSession'; static const result = 'result'; static const selectedDeviceId = 'selectedDeviceId'; static const selections = 'selections'; + static const start = 'start'; static const supported = 'supported'; static const supportsForceExternal = 'supportsForceExternal'; static const textDocument = 'textDocument'; @@ -393,6 +396,31 @@ class EditorSelection with Serializable { }; } +/// A range in the editor expressed as (zero-based) start and end positions. +class EditorRange with Serializable { + EditorRange({required this.start, required this.end}); + + EditorRange.fromJson(Map map) + : this( + start: CursorPosition.fromJson( + map[Field.start] as Map, + ), + end: CursorPosition.fromJson(map[Field.end] as Map), + ); + + /// The range's start position. + final CursorPosition start; + + /// The range's end position. + final CursorPosition end; + + @override + Map toJson() => { + Field.start: start.toJson(), + Field.end: end.toJson(), + }; +} + /// Representation of a single cursor position in the editor. /// /// The cursor position is after the given [character] of the [line]. @@ -430,12 +458,23 @@ class CursorPosition with Serializable { /// The result of an `editableArguments` request. class EditableArgumentsResult with Serializable { - EditableArgumentsResult({required this.args, this.name, this.documentation}); + EditableArgumentsResult({ + required this.args, + this.name, + this.documentation, + this.range, + }); EditableArgumentsResult.fromJson(Map map) : this( name: map[Field.name] as String?, documentation: map[Field.documentation] as String?, + range: + (map[Field.range] as Map?) == null + ? null + : EditorRange.fromJson( + map[Field.range] as Map, + ), args: (map[Field.arguments] as List? ?? []) .cast>() @@ -446,9 +485,15 @@ class EditableArgumentsResult with Serializable { final List args; final String? name; final String? documentation; + final EditorRange? range; @override - Map toJson() => {Field.arguments: args}; + Map toJson() => { + Field.arguments: args, + Field.name: name, + Field.documentation: documentation, + Field.range: range, + }; } /// Errors that the Analysis Server returns for failed argument edits. diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart index ac4ed486a7f..df50feb25b2 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart @@ -21,6 +21,7 @@ typedef EditableWidgetData = String? name, String? documentation, String? fileUri, + EditorRange? range, }); typedef EditArgumentFunction = @@ -51,6 +52,7 @@ class PropertyEditorController extends DisposableController String? get widgetName => _editableWidgetData.value?.name; String? get widgetDocumentation => _editableWidgetData.value?.documentation; String? get fileUri => _editableWidgetData.value?.fileUri; + EditorRange? get widgetRange => _editableWidgetData.value?.range; ValueListenable get shouldReconnect => _shouldReconnect; final _shouldReconnect = ValueNotifier(false); @@ -100,6 +102,7 @@ class PropertyEditorController extends DisposableController properties: [], name: null, documentation: null, + range: null, fileUri: textDocument.uriAsString, ); return; @@ -147,6 +150,30 @@ class PropertyEditorController extends DisposableController ); } + int hashProperty(EditableProperty property) { + final widgetData = editableWidgetData.value; + if (widgetData == null) { + return Object.hash(property.name, property.type); + } + final range = widgetRange; + return range == null + ? Object.hash( + property.name, + property.type, + property.value, // Include the property value. + widgetName, + fileUri, + ) + : Object.hash( + property.name, + property.type, + fileUri, + widgetName, + range.start.line, // Include the start position of the property. + range.start.character, + ); + } + Future _updateWithEditableArgs({ required TextDocument textDocument, required CursorPosition cursorPosition, @@ -166,11 +193,14 @@ class PropertyEditorController extends DisposableController .where((property) => !property.isDeprecated || property.hasArgument) .toList(); final name = result?.name; + final range = result?.range; + _editableWidgetData.value = ( properties: properties, name: name, documentation: result?.documentation, fileUri: _currentDocument?.uriAsString, + range: range, ); filterData(activeFilter.value); // Register impression. @@ -195,6 +225,7 @@ class PropertyEditorController extends DisposableController EditableArgumentsResult? editableArgsResult, TextDocument? document, CursorPosition? cursorPosition, + EditorRange? range, }) { setActiveFilter(); if (editableArgsResult != null) { @@ -204,6 +235,7 @@ class PropertyEditorController extends DisposableController name: editableArgsResult.name, documentation: editableArgsResult.documentation, fileUri: document?.uriAsString, + range: range, ); } if (document != null) { diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart index 66fd3d44fee..59ad8b0252e 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart @@ -208,6 +208,8 @@ class _TextInputState extends State<_TextInput> late final FocusNode _focusNode; + late final TextEditingController _controller; + late String _currentValue; @override @@ -215,6 +217,7 @@ class _TextInputState extends State<_TextInput> super.initState(); _currentValue = widget.property.valueDisplay; _focusNode = FocusNode(debugLabel: 'text-input-${widget.property.name}'); + _controller = TextEditingController(text: widget.property.valueDisplay); addAutoDisposeListener(_focusNode, () async { if (_focusNode.hasFocus) return; @@ -223,12 +226,20 @@ class _TextInputState extends State<_TextInput> }); } + @override + void didUpdateWidget(_TextInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.property != widget.property) { + _setValueAndMaintainSelection(widget.property.valueDisplay); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return TextFormField( focusNode: _focusNode, - initialValue: widget.property.valueDisplay, + controller: _controller, enabled: widget.property.isEditable, autovalidateMode: AutovalidateMode.onUserInteraction, validator: (text) => inputValidator(text, property: widget.property), @@ -259,6 +270,28 @@ class _TextInputState extends State<_TextInput> editPropertyCallback: widget.editProperty, ); } + + /// Sets the text field's value to [newValue]. + /// + /// Determines what the correct text selection should be based on the previous + /// selection. Without this, the entire text field contents would be selected + /// after editing a property. For details, see: + /// https://github.com/flutter/flutter/issues/161596 + void _setValueAndMaintainSelection(String newValue) { + final previousSelection = _controller.selection; + // If the previous selection is in range of the new text, use it. Otherwise, + // set the empty selection at the end of the string. + final newSelection = + (newValue.length < previousSelection.end || + newValue.length < previousSelection.start) + ? TextSelection.collapsed(offset: newValue.length) + : previousSelection; + // Set the new value in the controller with the new selection. + _controller.value = TextEditingValue( + text: newValue, + selection: newSelection, + ); + } } mixin _PropertyInputMixin on State { 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 aab7b0d8e96..952de3b0ebc 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 @@ -57,7 +57,8 @@ class PropertyEditorView extends StatelessWidget { return [introSentence, const HowToUseMessage()]; } - final (:properties, :name, :documentation, :fileUri) = editableWidgetData; + final (:properties, :name, :documentation, :fileUri, :range) = + editableWidgetData; if (fileUri != null && !fileUri.endsWith('.dart')) { return [const NoDartCodeMessage(), const HowToUseMessage()]; } @@ -123,8 +124,7 @@ class _PropertiesListState extends State<_PropertiesList> { for (final property in properties) _EditablePropertyItem( property: property, - editProperty: widget.controller.editArgument, - widgetDocumentation: widget.controller.widgetDocumentation, + controller: widget.controller, ), ].joinWith(const PaddedDivider.noPadding()), ); @@ -136,13 +136,11 @@ class _PropertiesListState extends State<_PropertiesList> { class _EditablePropertyItem extends StatelessWidget { const _EditablePropertyItem({ required this.property, - required this.editProperty, - required this.widgetDocumentation, + required this.controller, }); final EditableProperty property; - final EditArgumentFunction editProperty; - final String? widgetDocumentation; + final PropertyEditorController controller; @override Widget build(BuildContext context) { @@ -162,13 +160,13 @@ class _EditablePropertyItem extends StatelessWidget { ), child: _InfoTooltip( property: property, - widgetDocumentation: widgetDocumentation, + widgetDocumentation: controller.widgetDocumentation, ), ), Expanded( child: _PropertyInput( property: property, - editProperty: editProperty, + controller: controller, ), ), ], @@ -354,16 +352,16 @@ class _InfoTooltip extends StatelessWidget { } class _PropertyInput extends StatelessWidget { - const _PropertyInput({required this.property, required this.editProperty}); + const _PropertyInput({required this.property, required this.controller}); final EditableProperty property; - final EditArgumentFunction editProperty; + final PropertyEditorController controller; @override Widget build(BuildContext context) { - final argType = property.type; - final propertyKey = Key(property.hashCode.toString()); - switch (argType) { + final editProperty = controller.editArgument; + final propertyKey = Key(controller.hashProperty(property).toString()); + switch (property.type) { case boolType: return BooleanInput( key: propertyKey,