diff --git a/packages/flame/lib/src/devtools/connectors/component_tree_connector.dart b/packages/flame/lib/src/devtools/connectors/component_tree_connector.dart index 0e1ea1abb6a..4bcc9f03f6b 100644 --- a/packages/flame/lib/src/devtools/connectors/component_tree_connector.dart +++ b/packages/flame/lib/src/devtools/connectors/component_tree_connector.dart @@ -30,15 +30,24 @@ class ComponentTreeNode { final int id; final String name; final String toStringText; + final bool isPositionComponent; final List children; - ComponentTreeNode(this.id, this.name, this.toStringText, this.children); + ComponentTreeNode( + this.id, + this.name, + this.toStringText, + // ignore: avoid_positional_boolean_parameters + this.isPositionComponent, + this.children, + ); ComponentTreeNode.fromComponent(Component component) : this( component.hashCode, component.runtimeType.toString(), component.toString(), + component is PositionComponent, component.children.map(ComponentTreeNode.fromComponent).toList(), ); @@ -47,6 +56,7 @@ class ComponentTreeNode { json['id'] as int, json['name'] as String, json['toString'] as String, + json['isPositionComponent'] as bool, (json['children'] as List) .map((e) => ComponentTreeNode.fromJson(e as Map)) .toList(), @@ -57,6 +67,7 @@ class ComponentTreeNode { 'id': id, 'name': name, 'toString': toStringText, + 'isPositionComponent': isPositionComponent, 'children': children.map((e) => e.toJson()).toList(), }; } diff --git a/packages/flame/lib/src/devtools/connectors/position_component_attributes_connector.dart b/packages/flame/lib/src/devtools/connectors/position_component_attributes_connector.dart new file mode 100644 index 00000000000..4839f2f18d0 --- /dev/null +++ b/packages/flame/lib/src/devtools/connectors/position_component_attributes_connector.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flame/components.dart'; +import 'package:flame/src/devtools/dev_tools_connector.dart'; + +class PositionComponentAttributesConnector extends DevToolsConnector { + @override + void init() { + registerExtension( + 'ext.flame_devtools.getPositionComponentAttributes', + (method, parameters) async { + final id = int.tryParse(parameters['id'] ?? ''); + + final positionComponent = findComponent(id); + + if (positionComponent != null) { + return ServiceExtensionResponse.result( + json.encode({ + 'id': id, + 'x': positionComponent.x, + 'y': positionComponent.y, + 'width': positionComponent.width, + 'height': positionComponent.height, + }), + ); + } else { + return ServiceExtensionResponse.error( + ServiceExtensionResponse.extensionError, + 'No PositionComponent found with id: $id', + ); + } + }, + ); + + registerExtension( + 'ext.flame_devtools.setPositionComponentAttributes', + (method, parameters) async { + final id = int.tryParse(parameters['id'] ?? ''); + final attribute = parameters['attribute']; + + final positionComponent = findComponent(id); + + if (positionComponent != null) { + if (attribute == 'x') { + positionComponent.x = double.parse(parameters['value']!); + } else if (attribute == 'y') { + positionComponent.y = double.parse(parameters['value']!); + } else if (attribute == 'width') { + positionComponent.width = double.parse(parameters['value']!); + } else if (attribute == 'height') { + positionComponent.height = double.parse(parameters['value']!); + } else { + return ServiceExtensionResponse.error( + ServiceExtensionResponse.extensionError, + 'Invalid attribute: $attribute', + ); + } + return ServiceExtensionResponse.result('Success'); + } else { + return ServiceExtensionResponse.error( + ServiceExtensionResponse.extensionError, + 'No PositionComponent found with id: $id', + ); + } + }, + ); + } +} diff --git a/packages/flame/lib/src/devtools/dev_tools_connector.dart b/packages/flame/lib/src/devtools/dev_tools_connector.dart index d2ca9659488..2bcb26b68d3 100644 --- a/packages/flame/lib/src/devtools/dev_tools_connector.dart +++ b/packages/flame/lib/src/devtools/dev_tools_connector.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:flame/components.dart'; import 'package:flame/debug.dart'; import 'package:flame/game.dart'; import 'package:flutter/foundation.dart'; @@ -29,4 +30,23 @@ abstract class DevToolsConnector { /// Here you can do clean-up before a new game is set in the connector. void disposeGame() {} + + /// Finds a component in the game tree by its id. + /// + /// Returns the component if found, otherwise null. + T? findComponent(int? id) { + T? component; + game.propagateToChildren( + (c) { + if (c.hashCode == id) { + component = c; + return false; + } + return true; + }, + includeSelf: true, + ); + + return component; + } } diff --git a/packages/flame/lib/src/devtools/dev_tools_service.dart b/packages/flame/lib/src/devtools/dev_tools_service.dart index b73250a16e7..8cdbd067e74 100644 --- a/packages/flame/lib/src/devtools/dev_tools_service.dart +++ b/packages/flame/lib/src/devtools/dev_tools_service.dart @@ -4,6 +4,7 @@ import 'package:flame/src/devtools/connectors/component_snapshot_connector.dart' import 'package:flame/src/devtools/connectors/component_tree_connector.dart'; import 'package:flame/src/devtools/connectors/debug_mode_connector.dart'; import 'package:flame/src/devtools/connectors/game_loop_connector.dart'; +import 'package:flame/src/devtools/connectors/position_component_attributes_connector.dart'; import 'package:flame/src/devtools/dev_tools_connector.dart'; /// When [DevToolsService] is initialized by the [FlameGame] it will call @@ -37,6 +38,7 @@ class DevToolsService { ComponentTreeConnector(), GameLoopConnector(), ComponentSnapshotConnector(), + PositionComponentAttributesConnector(), ]; /// This method is called every time a new game is set in the service and it diff --git a/packages/flame_devtools/lib/providers/position_component_attributes_provider.dart b/packages/flame_devtools/lib/providers/position_component_attributes_provider.dart new file mode 100644 index 00000000000..351a0eb781e --- /dev/null +++ b/packages/flame_devtools/lib/providers/position_component_attributes_provider.dart @@ -0,0 +1,7 @@ +import 'package:flame_devtools/repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final positionComponentAttributesProvider = + FutureProvider.family((ref, id) async { + return Repository.getPositionComponentAttributes(id: id); +}); diff --git a/packages/flame_devtools/lib/repository.dart b/packages/flame_devtools/lib/repository.dart index 8bb08c98edd..635b4509b48 100644 --- a/packages/flame_devtools/lib/repository.dart +++ b/packages/flame_devtools/lib/repository.dart @@ -76,4 +76,56 @@ sealed class Repository { ); return snapshotResponse.json!['snapshot'] as String?; } + + static Future getPositionComponentAttributes({ + int? id, + }) async { + final potentialPositionComponentResponse = + await serviceManager.callServiceExtensionOnMainIsolate( + 'ext.flame_devtools.getPositionComponentAttributes', + args: {'id': id}, + ); + + return PositionComponentAttributes.fromJson( + potentialPositionComponentResponse.json!, + ); + } + + static Future setPositionComponentAttribute({ + required String attribute, + required dynamic value, + int? id, + }) async { + await serviceManager.callServiceExtensionOnMainIsolate( + 'ext.flame_devtools.setPositionComponentAttributes', + args: { + 'id': id, + 'attribute': attribute, + 'value': value, + }, + ); + } +} + +class PositionComponentAttributes { + final double x; + final double y; + final double width; + final double height; + + PositionComponentAttributes({ + required this.x, + required this.y, + required this.width, + required this.height, + }); + + factory PositionComponentAttributes.fromJson(Map json) { + return PositionComponentAttributes( + x: json['x'] as double, + y: json['y'] as double, + width: json['width'] as double, + height: json['height'] as double, + ); + } } diff --git a/packages/flame_devtools/lib/widgets/component_tree.dart b/packages/flame_devtools/lib/widgets/component_tree.dart index 687511422e1..0e1fb7abee3 100644 --- a/packages/flame_devtools/lib/widgets/component_tree.dart +++ b/packages/flame_devtools/lib/widgets/component_tree.dart @@ -3,6 +3,7 @@ import 'package:devtools_app_shared/ui.dart' as devtools_ui; import 'package:flame_devtools/widgets/component_snapshot.dart'; import 'package:flame_devtools/widgets/component_tree_model.dart'; import 'package:flame_devtools/widgets/debug_mode_button.dart'; +import 'package:flame_devtools/widgets/position_component_attributes_form.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -152,6 +153,13 @@ class ComponentSection extends ConsumerWidget { 'Children: ${node.children.length}', style: textStyle, ), + if (node.isPositionComponent) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: PositionComponentAttributesForm( + componentId: node.id, + ), + ), Text( 'toString:\n${node.toStringText}', style: textStyle, diff --git a/packages/flame_devtools/lib/widgets/incremental_number_form_field.dart b/packages/flame_devtools/lib/widgets/incremental_number_form_field.dart new file mode 100644 index 00000000000..ba2a735544c --- /dev/null +++ b/packages/flame_devtools/lib/widgets/incremental_number_form_field.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +class IncrementalNumberFormField extends StatefulWidget { + const IncrementalNumberFormField({ + required this.initialValue, + required this.label, + this.onChanged, + super.key, + }); + + final String label; + final T initialValue; + final void Function(T)? onChanged; + + @override + State> createState() => + _IncrementalNumberFormFieldState(); +} + +class _IncrementalNumberFormFieldState + extends State> { + late final _controller = TextEditingController() + ..text = widget.initialValue.toString(); + + String? errorText; + + @override + void didUpdateWidget(covariant IncrementalNumberFormField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.initialValue != widget.initialValue) { + _controller.text = widget.initialValue.toString(); + errorText = null; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + T _parse() { + if (T == double) { + return double.parse(_controller.text) as T; + } else { + return int.parse(_controller.text) as T; + } + } + + void _tryUpdate(String value) { + try { + final value = _parse(); + _update(value); + } on Exception catch (_) { + setState(() { + errorText = 'Invalid number'; + }); + } + } + + void _update(T v) { + setState(() { + errorText = null; + }); + + widget.onChanged?.call(v); + } + + void _increment() { + final value = _parse() + 1 as T; + _update(value); + _controller.text = value.toString(); + } + + void _decrement() { + final value = _parse() - 1 as T; + _update(value); + _controller.text = value.toString(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + IconButton( + onPressed: _decrement, + icon: const Icon(Icons.remove), + ), + const SizedBox(width: 8), + SizedBox( + width: 100, + child: TextField( + decoration: InputDecoration( + labelText: widget.label, + errorText: errorText, + ), + controller: _controller, + onChanged: _tryUpdate, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _increment, + icon: const Icon(Icons.add), + ), + ], + ); + } +} diff --git a/packages/flame_devtools/lib/widgets/position_component_attributes_form.dart b/packages/flame_devtools/lib/widgets/position_component_attributes_form.dart new file mode 100644 index 00000000000..92f54c57e18 --- /dev/null +++ b/packages/flame_devtools/lib/widgets/position_component_attributes_form.dart @@ -0,0 +1,100 @@ +import 'package:flame_devtools/providers/position_component_attributes_provider.dart'; +import 'package:flame_devtools/repository.dart'; +import 'package:flame_devtools/widgets/incremental_number_form_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PositionComponentAttributesForm extends ConsumerWidget { + const PositionComponentAttributesForm({ + required this.componentId, + super.key, + }); + + final int componentId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final attributesData = ref.watch( + positionComponentAttributesProvider( + componentId, + ), + ); + + return attributesData.when( + error: (e, s) => const Text( + 'Error loading component attributes', + ), + loading: () => const Center(child: CircularProgressIndicator()), + data: (attributes) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Position', + style: Theme.of(context).textTheme.labelLarge, + ), + Row( + children: [ + IncrementalNumberFormField( + label: 'X', + initialValue: attributes.x, + onChanged: (v) { + Repository.setPositionComponentAttribute( + id: componentId, + attribute: 'x', + value: v, + ); + }, + ), + const SizedBox(width: 8), + IncrementalNumberFormField( + label: 'Y', + initialValue: attributes.y, + onChanged: (v) { + Repository.setPositionComponentAttribute( + id: componentId, + attribute: 'y', + value: v, + ); + }, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Size', + style: Theme.of(context).textTheme.labelLarge, + ), + Row( + children: [ + IncrementalNumberFormField( + label: 'Width', + initialValue: attributes.width, + onChanged: (v) { + Repository.setPositionComponentAttribute( + id: componentId, + attribute: 'width', + value: v, + ); + }, + ), + const SizedBox(width: 8), + IncrementalNumberFormField( + label: 'Height', + initialValue: attributes.height, + onChanged: (v) { + Repository.setPositionComponentAttribute( + id: componentId, + attribute: 'height', + value: v, + ); + }, + ), + ], + ), + ], + ); + }, + ); + } +}