diff --git a/.github/cspell.json b/.github/cspell.json index 6c2f446..862c39d 100644 --- a/.github/cspell.json +++ b/.github/cspell.json @@ -28,6 +28,8 @@ "cubit", "cubits", "flame", + "lerp", + "LTRB", "LTWH", "mocktail", "Zanardo" diff --git a/CLAUDE.md b/CLAUDE.md index cb96944..ba17056 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,12 @@ three pass. `file_system_client`, `project_repository`, `component_repository` (components stored in `/.flame_editor/components/.json`) — see "Existing modules" in [docs/architecture.md](docs/architecture.md). -- Component types: `rectangle` (position, anchor and angle — edited only on +- Component types: `polygon` (a Flame `PolygonComponent`: position, anchor + and angle — children only —, vertices and paint; no size, which is the + vertices' bounding box; it starts as a triangle or a square, and its + vertices are edited in the vertices editor dialog, `lib/vertices_editor/`, + by dragging, adding from edge midpoints and deleting — see "Polygons" in + [docs/architecture.md](docs/architecture.md)), `rectangle` (position, anchor and angle — edited only on children —, size, and paint with color and fill style), `position` (a plain Flame `PositionComponent`: position, anchor and angle — children only — and size; draws nothing itself, useful as a container) and `circle` (a Flame `CircleComponent`: diff --git a/docs/architecture.md b/docs/architecture.md index f32a56d..f1305ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,10 +129,11 @@ dependencies: | Data | `packages/key_value_storage_client` | Generic string key-value storage (`shared_preferences`) | | Data | `packages/file_system_client` | Native directory picker (`file_selector`), directory/file existence, list/read/write/delete files | | Repository | `packages/project_repository` | `Project` and `ProjectSettings` models; pick/open projects; last 10 recent projects; read/write project settings (`/.flame_editor/settings.json`) and pick a project-relative output folder | -| Repository | `packages/component_repository` | Sealed `Component` model (one subclass per `ComponentType`), shared property models (`ComponentPosition`, `ComponentSize`, `ComponentPaint`), capability interfaces and the `ComponentTree` extension for nested children (including child name validation); list/create/save/delete a project's components (one JSON file each in `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation") | +| Repository | `packages/component_repository` | Sealed `Component` model (one subclass per `ComponentType`), shared property models (`ComponentPosition`, `ComponentSize`, `ComponentPaint`, `ComponentVertices`…), the `PolygonShape` presets new polygons start with, capability interfaces and the `ComponentTree` extension for nested children (including child name validation); list/create/save/delete a project's components (one JSON file each in `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation") | | Bloc + view | `lib/home/` | Landing screen: "Open project" button and recent projects list | | Bloc + view | `lib/project/` | Project dashboard: lists components, add (name + type dialog → editor), edit, delete (with confirmation; also deletes the generated code), settings icon (opens the settings dialog). Pushed from home with `ProjectPage.route` | | Bloc + view | `lib/component_editor/` | Component editor, `ComponentEditorPage.route(project, component)`: component tree (left, add/remove children, root selected by default), Flame preview (center, magenta border around the selected node; a toolbar above it toggles that highlight), properties panel for the selected node (right; children also get a Name field), save button + Cmd/Ctrl+S (also generates the Dart code), unsaved-changes marker in the title, `PopScope` prompt (cancel / discard / save) when leaving with unsaved changes | +| Bloc + view | `lib/vertices_editor/` | Vertices editor dialog, `VerticesEditorDialog.show(context, vertices:, paint:)`: a `CustomPaint` canvas (`VerticesCanvas`, `VerticesPainter`) where the user drags vertices, adds one from the "+" handle in the middle of an edge, and deletes the selected one (Delete/Backspace, context menu or button; at least 3 are kept). A side bar (`VerticesList`) has X/Y fields per vertex to type exact coordinates, and a delete button per row; selecting a row or focusing its fields selects the vertex. `VerticesEditorCubit` edits a working copy; Apply returns it normalized, Cancel/Esc returns `null`. `VerticesViewTransform` maps local coordinates to the canvas (fitted once when the dialog opens) and does the hit testing | | Bloc + view | `lib/project_settings/` | Project settings dialog, `ProjectSettingsDialog.show(context, project)`: output path for generated Dart code (default `lib/components`, text field + Browse) | `project_repository` and `component_repository` have no Flutter imports, but @@ -150,11 +151,11 @@ component types only declare which ones they have: | Piece | Where | Example | | --- | --- | --- | -| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentAngle`, `ComponentSize`, `ComponentRadius`, `ComponentPaint` | -| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `RotatedComponent`, `SizedComponent`, `RadiusComponent`, `PaintedComponent` | -| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, SizedComponent`, `CircleComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, RadiusComponent, PaintedComponent` | +| Value object (Equatable, JSON, defaults) | `component_repository/lib/src/models/` | `ComponentPosition`, `ComponentAnchor`, `ComponentAngle`, `ComponentSize`, `ComponentRadius`, `ComponentVertices`, `ComponentPaint` | +| Capability interface | `component_repository/lib/src/models/component_capabilities.dart` | `PositionedComponent`, `AnchoredComponent`, `RotatedComponent`, `SizedComponent`, `RadiusComponent`, `VerticesComponent`, `PaintedComponent` | +| Component type | `ComponentType` enum + a subclass in `component.dart`'s library (`part` file) | `RectangleComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, SizedComponent, PaintedComponent`, `PositionComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, SizedComponent`, `CircleComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, RadiusComponent, PaintedComponent`, `PolygonComponent implements PositionedComponent, AnchoredComponent, RotatedComponent, VerticesComponent, PaintedComponent` | | Bloc event | `lib/component_editor/bloc/` | `ComponentEditorSizeChanged` (edits the selected node if it is a `SizedComponent`) | -| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `PositionEditor`, `AnchorEditor`, `AngleEditor`, `SizeEditor`, `RadiusEditor`, `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | +| Editor widget (`value` + `onChanged`, bloc-agnostic) | `lib/component_editor/widgets/property_editors/` | `PositionEditor`, `AnchorEditor`, `AngleEditor`, `SizeEditor`, `RadiusEditor`, `VerticesEditor` (a button that opens the vertices editor dialog), `PaintEditor`, built from `NumberField`, `ColorField`, `EnumField` | | Panel wiring | `lib/component_editor/widgets/properties_panel.dart` | `if (component case SizedComponent c) SizeEditor(...)` | | Flame mapping | `lib/component_editor/widgets/preview/flame_mapping.dart` | `applyPosition`, `applyAnchor`, `applyAngle`, `applySize`, `applyRadius`, `applyPaint`, `buildFlameComponent` | | Code generation | `component_repository/lib/src/code_generation/` | `ComponentCodeGenerator._flameClass` / `_arguments`, `flameMemberNames` in `dart_names.dart` | @@ -175,6 +176,26 @@ event, an editor widget, a branch in `PropertiesPanel`, in code must match the preview), then implement the capability on the types that have it. +### Polygons + +A `PolygonComponent` is shaped by its `ComponentVertices`: points in the +component's local coordinates, in order, at least 3 (Flame asserts it). +Its Flame size is the bounding box of the vertices (Flame's +`shrinkToBounds`), so polygons have **no size property**. New polygons +start as a triangle or a square (`PolygonShape`, picked in the add +component dialog and in the tree's add-child menu); concave and +self-intersecting shapes are allowed. + +Flame moves the vertices so that the top left of their bounding box is +at the origin, so the vertices editor stores them normalized +(`ComponentVertices.normalized`) and the stored data matches what Flame +renders. A Flame polygon can't change its number of vertices and can't be +given new ones from outside (`refreshVertices` is protected), so +`haveSameShape` is false when a polygon's vertices change and the preview +rebuilds it. Flame also changes the list it is given, so the preview and +the generated code always pass a new, non-const list, as the first +(positional) constructor argument. + ### Component tree Like in Flame, every `Component` has `children` (stored nested in the same @@ -204,7 +225,8 @@ only: the generated code uses a plain `PositionComponent`. The preview highlights the selected node with a magenta border while `isSelectionHighlighted` is on (the toolbar toggle; on by default, not persisted). Only nodes with a size (`SizedComponent`) or a radius -(`RadiusComponent`, whose Flame size is the circle's bounding box) are +(`RadiusComponent`, whose Flame size is the circle's bounding box) or +vertices (`VerticesComponent`, highlighted around their bounding box) are highlighted. The border is drawn by `SelectionHighlight`, a world-level overlay that targets the Flame component found with `flameNodeAt`, rather than a child of it: diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index fce1c19..b6674a2 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -39,6 +39,7 @@ class ComponentEditorBloc on(_onAngleChanged); on(_onSizeChanged); on(_onRadiusChanged); + on(_onVerticesChanged); on(_onPaintChanged); on(_onSelectionHighlightToggled); on(_onSaveRequested); @@ -128,6 +129,7 @@ class ComponentEditorBloc takenClassNames: state.takenClassNames.keys.toSet(), ), event.type, + polygonShape: event.polygonShape, ); emit( state.copyWith( @@ -231,6 +233,18 @@ class ComponentEditorBloc }); } + void _onVerticesChanged( + ComponentEditorVerticesChanged event, + Emitter emit, + ) { + _editSelected(emit, (node) { + if (node case final VerticesComponent node) { + return node.copyWithVertices(event.vertices); + } + return node; + }); + } + void _onPaintChanged( ComponentEditorPaintChanged event, Emitter emit, diff --git a/lib/component_editor/bloc/component_editor_event.dart b/lib/component_editor/bloc/component_editor_event.dart index 490ba3f..76f7b8c 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -28,15 +28,20 @@ final class ComponentEditorNodeSelected extends ComponentEditorEvent { } /// The user added a component of [type] inside the selected node. +/// +/// Polygons start with the vertices of [polygonShape]. final class ComponentEditorChildAdded extends ComponentEditorEvent { /// Creates a [ComponentEditorChildAdded] event. - const new(this.type); + const new(this.type, {this.polygonShape = PolygonShape.triangle}); /// The type of the new component. final ComponentType type; + /// The shape of the new component, if it is a polygon. + final PolygonShape polygonShape; + @override - List get props => [type]; + List get props => [type, polygonShape]; } /// The user removed the node at [path] from the component tree. @@ -128,6 +133,18 @@ final class ComponentEditorRadiusChanged extends ComponentEditorEvent { List get props => [radius]; } +/// The user edited the vertices of the selected [VerticesComponent]. +final class ComponentEditorVerticesChanged extends ComponentEditorEvent { + /// Creates a [ComponentEditorVerticesChanged] event. + const new(this.vertices); + + /// The new vertices. + final ComponentVertices vertices; + + @override + List get props => [vertices]; +} + /// The user edited the paint of the selected [PaintedComponent]. final class ComponentEditorPaintChanged extends ComponentEditorEvent { /// Creates a [ComponentEditorPaintChanged] event. diff --git a/lib/component_editor/widgets/component_tree_panel.dart b/lib/component_editor/widgets/component_tree_panel.dart index a00f3da..0a872e8 100644 --- a/lib/component_editor/widgets/component_tree_panel.dart +++ b/lib/component_editor/widgets/component_tree_panel.dart @@ -37,19 +37,35 @@ class ComponentTreePanel extends StatelessWidget { style: Theme.of(context).textTheme.titleSmall, ), ), - PopupMenuButton( + PopupMenuButton( tooltip: 'Add child', icon: const Icon(Icons.add), - onSelected: (type) => bloc.add(ComponentEditorChildAdded(type)), + onSelected: bloc.add, itemBuilder: (_) => [ for (final type in ComponentType.values) - PopupMenuItem( - value: type, - child: ListTile( - leading: Icon(iconFor(type)), - title: Text(type.label), + if (type == ComponentType.polygon) + // One entry per shape a new polygon can start with. + for (final shape in PolygonShape.values) + PopupMenuItem( + value: ComponentEditorChildAdded( + type, + polygonShape: shape, + ), + child: ListTile( + leading: Icon(iconFor(type)), + title: Text( + '${type.label} (${shape.label.toLowerCase()})', + ), + ), + ) + else + PopupMenuItem( + value: ComponentEditorChildAdded(type), + child: ListTile( + leading: Icon(iconFor(type)), + title: Text(type.label), + ), ), - ), ], ), IconButton( @@ -92,6 +108,7 @@ class ComponentTreePanel extends StatelessWidget { ComponentType.rectangle => Icons.crop_square, ComponentType.position => Icons.crop_free, ComponentType.circle => Icons.circle_outlined, + ComponentType.polygon => Icons.pentagon_outlined, }; } diff --git a/lib/component_editor/widgets/preview/component_preview_game.dart b/lib/component_editor/widgets/preview/component_preview_game.dart index 97d9875..ec3e37c 100644 --- a/lib/component_editor/widgets/preview/component_preview_game.dart +++ b/lib/component_editor/widgets/preview/component_preview_game.dart @@ -39,11 +39,12 @@ class ComponentPreviewGame extends FlameGame { /// Renders [component] instead of the current one, highlighting the node /// at [highlightedPath] (see [ComponentTree]), if any. /// - /// Only nodes with a size or a radius ([SizedComponent], - /// [RadiusComponent]) are highlighted. + /// Only nodes with a size, a radius or vertices ([SizedComponent], + /// [RadiusComponent], [VerticesComponent]) are highlighted. /// /// The existing Flame components are updated in place, unless components - /// were added, removed or changed type (see [haveSameShape]). + /// were added, removed or changed type, or polygons got new vertices (see + /// [haveSameShape]). void show(Component component, {List? highlightedPath}) { final previous = _component; _component = component; @@ -75,6 +76,8 @@ class ComponentPreviewGame extends FlameGame { } static bool _hasBounds(Component node) { - return node is SizedComponent || node is RadiusComponent; + return node is SizedComponent || + node is RadiusComponent || + node is VerticesComponent; } } diff --git a/lib/component_editor/widgets/preview/flame_mapping.dart b/lib/component_editor/widgets/preview/flame_mapping.dart index 456bec8..cf3011c 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -28,6 +28,10 @@ flame.PositionComponent _build(Component component) { final target = switch (component) { RectangleComponent() => flame.RectangleComponent(), CircleComponent() => flame.CircleComponent(), + // Flame changes the list it is given, so it gets a new one. + PolygonComponent(:final vertices) => flame.PolygonComponent([ + for (final vertex in vertices.vertices) flame.Vector2(vertex.x, vertex.y), + ]), // Outlined, since a plain PositionComponent would be invisible. PositionComponent() => PreviewPositionComponent(), }; @@ -39,10 +43,18 @@ flame.PositionComponent _build(Component component) { /// Whether [a] and [b] have the same types arranged in the same tree, so /// that the Flame components built for [a] can be updated to show [b]. +/// +/// Polygons with different vertices don't have the same shape: a Flame +/// polygon can't change its number of vertices, nor be given new ones from +/// outside. bool haveSameShape(Component a, Component b) { if (a.type != b.type || a.children.length != b.children.length) { return false; } + if ((a, b) case (final VerticesComponent a, final VerticesComponent b) + when a.vertices != b.vertices) { + return false; + } for (var i = 0; i < a.children.length; i++) { if (!haveSameShape(a.children[i], b.children[i])) return false; } diff --git a/lib/component_editor/widgets/properties_panel.dart b/lib/component_editor/widgets/properties_panel.dart index 9dcdf8b..01820d9 100644 --- a/lib/component_editor/widgets/properties_panel.dart +++ b/lib/component_editor/widgets/properties_panel.dart @@ -75,6 +75,16 @@ class PropertiesPanel extends StatelessWidget { onChanged: (radius) => bloc.add(ComponentEditorRadiusChanged(radius)), ), + if (component case final VerticesComponent polygon) + VerticesEditor( + value: polygon.vertices, + paint: switch (component) { + PaintedComponent(:final paint) => paint, + _ => const ComponentPaint(), + }, + onChanged: (vertices) => + bloc.add(ComponentEditorVerticesChanged(vertices)), + ), if (component case final PaintedComponent painted) PaintEditor( value: painted.paint, diff --git a/lib/component_editor/widgets/property_editors/property_editors.dart b/lib/component_editor/widgets/property_editors/property_editors.dart index 26d50a3..8fc8a83 100644 --- a/lib/component_editor/widgets/property_editors/property_editors.dart +++ b/lib/component_editor/widgets/property_editors/property_editors.dart @@ -9,3 +9,4 @@ export 'position_editor.dart'; export 'property_section.dart'; export 'radius_editor.dart'; export 'size_editor.dart'; +export 'vertices_editor.dart'; diff --git a/lib/component_editor/widgets/property_editors/vertices_editor.dart b/lib/component_editor/widgets/property_editors/vertices_editor.dart new file mode 100644 index 0000000..8e270e8 --- /dev/null +++ b/lib/component_editor/widgets/property_editors/vertices_editor.dart @@ -0,0 +1,61 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/component_editor/widgets/property_editors/property_section.dart'; +import 'package:flame_component_editor/vertices_editor/vertices_editor.dart'; +import 'package:flutter/material.dart'; + +/// {@template vertices_editor} +/// Edits a [ComponentVertices] in a [VerticesEditorDialog]. Used by every +/// [VerticesComponent]. +/// {@endtemplate} +class VerticesEditor extends StatelessWidget { + /// {@macro vertices_editor} + const new({ + required this.value, + required this.onChanged, + this.paint = const ComponentPaint(), + super.key, + }); + + /// The current vertices. + final ComponentVertices value; + + /// The paint the dialog draws the polygon with. + final ComponentPaint paint; + + /// Called with the edited vertices. + final ValueChanged onChanged; + + Future _edit(BuildContext context) async { + final vertices = await VerticesEditorDialog.show( + context, + vertices: value, + paint: paint, + ); + if (vertices != null && vertices != value) onChanged(vertices); + } + + @override + Widget build(BuildContext context) { + final bounds = value.bounds; + return PropertySection( + title: 'Vertices', + children: [ + Text( + '${value.vertices.length} vertices, ' + '${_number(bounds.width)} × ${_number(bounds.height)}', + ), + OutlinedButton.icon( + icon: const Icon(Icons.edit_outlined), + label: const Text('Edit vertices'), + onPressed: () => _edit(context), + ), + ], + ); + } + + static String _number(double value) { + return value == value.truncateToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); + } +} diff --git a/lib/project/bloc/project_bloc.dart b/lib/project/bloc/project_bloc.dart index 918a858..5bb945b 100644 --- a/lib/project/bloc/project_bloc.dart +++ b/lib/project/bloc/project_bloc.dart @@ -53,6 +53,7 @@ class ProjectBloc extends Bloc { _project.path, event.name, event.type, + polygonShape: event.polygonShape, ); emit( state.copyWith( diff --git a/lib/project/bloc/project_event.dart b/lib/project/bloc/project_event.dart index ad3cb8f..57ab3f1 100644 --- a/lib/project/bloc/project_event.dart +++ b/lib/project/bloc/project_event.dart @@ -15,9 +15,11 @@ final class ProjectComponentsRequested extends ProjectEvent { } /// The user asked to create a component of [type] called [name]. +/// +/// Polygons start with the vertices of [polygonShape]. final class ProjectComponentCreateRequested extends ProjectEvent { /// Creates a [ProjectComponentCreateRequested] event. - const new(this.name, this.type); + const new(this.name, this.type, {this.polygonShape = PolygonShape.triangle}); /// The name of the component to create. final String name; @@ -25,8 +27,11 @@ final class ProjectComponentCreateRequested extends ProjectEvent { /// The type of the component to create. final ComponentType type; + /// The shape of the component to create, if it is a polygon. + final PolygonShape polygonShape; + @override - List get props => [name, type]; + List get props => [name, type, polygonShape]; } /// The user confirmed that [component] should be deleted. diff --git a/lib/project/view/project_view.dart b/lib/project/view/project_view.dart index 4d312e2..aa9c61a 100644 --- a/lib/project/view/project_view.dart +++ b/lib/project/view/project_view.dart @@ -104,7 +104,13 @@ class ProjectView extends StatelessWidget { final bloc = context.read(); final result = await AddComponentDialog.show(context); if (result == null) return; - bloc.add(ProjectComponentCreateRequested(result.name, result.type)); + bloc.add( + ProjectComponentCreateRequested( + result.name, + result.type, + polygonShape: result.polygonShape, + ), + ); } Future _editComponent(BuildContext context, Component component) async { diff --git a/lib/project/widgets/add_component_dialog.dart b/lib/project/widgets/add_component_dialog.dart index ff5877d..e5a56fc 100644 --- a/lib/project/widgets/add_component_dialog.dart +++ b/lib/project/widgets/add_component_dialog.dart @@ -1,11 +1,17 @@ import 'package:component_repository/component_repository.dart'; import 'package:flutter/material.dart'; -/// The name and type entered in the [AddComponentDialog]. -typedef AddComponentResult = ({String name, ComponentType type}); +/// The name, type and, for polygons, shape entered in the +/// [AddComponentDialog]. +typedef AddComponentResult = ({ + String name, + ComponentType type, + PolygonShape polygonShape, +}); /// {@template add_component_dialog} -/// Asks the user for the name and type of a new component. +/// Asks the user for the name and type of a new component, and for the +/// shape it starts with if it is a polygon. /// /// Pops with the entered [AddComponentResult], or `null` if cancelled. /// {@endtemplate} @@ -29,6 +35,7 @@ class AddComponentDialog extends StatefulWidget { class _AddComponentDialogState extends State { final _controller = TextEditingController(); ComponentType _type = ComponentType.values.first; + PolygonShape _polygonShape = PolygonShape.triangle; @override void dispose() { @@ -39,7 +46,8 @@ class _AddComponentDialogState extends State { void _submit() { final name = _controller.text.trim(); if (name.isEmpty) return; - Navigator.of(context).pop((name: name, type: _type)); + Navigator.of(context) + .pop((name: name, type: _type, polygonShape: _polygonShape)); } @override @@ -75,6 +83,20 @@ class _AddComponentDialogState extends State { DropdownMenuEntry(value: type, label: type.label), ], ), + if (_type == ComponentType.polygon) + DropdownMenu( + label: const Text('Shape'), + expandedInsets: EdgeInsets.zero, + initialSelection: _polygonShape, + requestFocusOnTap: false, + onSelected: (shape) { + if (shape != null) setState(() => _polygonShape = shape); + }, + dropdownMenuEntries: [ + for (final shape in PolygonShape.values) + DropdownMenuEntry(value: shape, label: shape.label), + ], + ), ], ), ), diff --git a/lib/vertices_editor/bloc/vertices_editor_cubit.dart b/lib/vertices_editor/bloc/vertices_editor_cubit.dart new file mode 100644 index 0000000..c443399 --- /dev/null +++ b/lib/vertices_editor/bloc/vertices_editor_cubit.dart @@ -0,0 +1,67 @@ +import 'package:bloc/bloc.dart'; +import 'package:component_repository/component_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'vertices_editor_state.dart'; + +/// {@template vertices_editor_cubit} +/// Edits a working copy of the vertices of a polygon, which the vertices +/// editor dialog returns when the user applies the changes. +/// {@endtemplate} +class VerticesEditorCubit extends Cubit { + /// {@macro vertices_editor_cubit} + new(ComponentVertices vertices) + : super(VerticesEditorState(initial: vertices)); + + /// Selects the vertex at [index], or nothing if it is `null`. + void vertexSelected(int? index) { + emit(state.copyWith(selectedIndex: () => index)); + } + + /// Moves the vertex at [index] to [x], [y]. + void vertexMoved(int index, double x, double y) { + emit( + state.copyWith( + vertices: [...state.vertices]..[index] = ComponentVertex(x, y), + ), + ); + } + + /// Adds a vertex at [x], [y] on the edge that starts at the vertex at + /// [edgeIndex], and selects it. + void vertexInserted(int edgeIndex, double x, double y) { + final index = edgeIndex + 1; + emit( + state.copyWith( + vertices: [...state.vertices]..insert(index, ComponentVertex(x, y)), + selectedIndex: () => index, + ), + ); + } + + /// Removes the selected vertex, unless the polygon would have fewer than + /// [ComponentVertices.minLength] vertices. + void selectedVertexDeleted() { + final index = state.selectedIndex; + if (index != null) vertexDeleted(index); + } + + /// Removes the vertex at [index], unless the polygon would have fewer + /// than [ComponentVertices.minLength] vertices. The selection stays on + /// the same vertex, or is cleared if it was the removed one. + void vertexDeleted(int index) { + if (!state.canDelete) return; + final selected = state.selectedIndex; + emit( + state.copyWith( + vertices: [...state.vertices]..removeAt(index), + selectedIndex: () => switch (selected) { + null => null, + _ when selected == index => null, + _ when selected > index => selected - 1, + _ => selected, + }, + ), + ); + } +} diff --git a/lib/vertices_editor/bloc/vertices_editor_state.dart b/lib/vertices_editor/bloc/vertices_editor_state.dart new file mode 100644 index 0000000..504e4a3 --- /dev/null +++ b/lib/vertices_editor/bloc/vertices_editor_state.dart @@ -0,0 +1,48 @@ +part of 'vertices_editor_cubit.dart'; + +/// The state of the [VerticesEditorCubit]. +final class VerticesEditorState extends Equatable { + /// Creates a [VerticesEditorState] editing [initial]. + new({ + required this.initial, + List? vertices, + this.selectedIndex, + }) : vertices = vertices ?? initial.vertices; + + /// The vertices the editor opened with. + final ComponentVertices initial; + + /// The edited vertices, in order. + final List vertices; + + /// The index of the selected vertex in [vertices], if any. + final int? selectedIndex; + + /// Whether a vertex can be deleted: a polygon needs at least + /// [ComponentVertices.minLength] vertices. + bool get canDelete => vertices.length > ComponentVertices.minLength; + + /// Whether the vertices were edited. + bool get hasChanges => result != initial.normalized(); + + /// The edited vertices, placed like Flame places them (see + /// [ComponentVertices.normalized]). + ComponentVertices get result => ComponentVertices(vertices).normalized(); + + /// Returns a copy of this state with the given fields replaced. + VerticesEditorState copyWith({ + List? vertices, + int? Function()? selectedIndex, + }) { + return VerticesEditorState( + initial: initial, + vertices: vertices ?? this.vertices, + selectedIndex: selectedIndex != null + ? selectedIndex() + : this.selectedIndex, + ); + } + + @override + List get props => [initial, vertices, selectedIndex]; +} diff --git a/lib/vertices_editor/vertices_editor.dart b/lib/vertices_editor/vertices_editor.dart new file mode 100644 index 0000000..aba9592 --- /dev/null +++ b/lib/vertices_editor/vertices_editor.dart @@ -0,0 +1,2 @@ +export 'bloc/vertices_editor_cubit.dart'; +export 'view/view.dart'; diff --git a/lib/vertices_editor/view/vertices_editor_dialog.dart b/lib/vertices_editor/view/vertices_editor_dialog.dart new file mode 100644 index 0000000..0254097 --- /dev/null +++ b/lib/vertices_editor/view/vertices_editor_dialog.dart @@ -0,0 +1,40 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/bloc/vertices_editor_cubit.dart'; +import 'package:flame_component_editor/vertices_editor/view/vertices_editor_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template vertices_editor_dialog} +/// A dialog to edit the vertices of a polygon on a canvas. +/// {@endtemplate} +class VerticesEditorDialog extends StatelessWidget { + /// {@macro vertices_editor_dialog} + const new({required this.vertices, required this.paint, super.key}); + + /// Shows the dialog editing [vertices], drawn with [paint], and returns + /// the applied vertices, or `null` if the user cancelled. + static Future show( + BuildContext context, { + required ComponentVertices vertices, + required ComponentPaint paint, + }) { + return showDialog( + context: context, + builder: (_) => VerticesEditorDialog(vertices: vertices, paint: paint), + ); + } + + /// The vertices the dialog opens with. + final ComponentVertices vertices; + + /// The paint the polygon is drawn with. + final ComponentPaint paint; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => VerticesEditorCubit(vertices), + child: VerticesEditorView(paint: paint), + ); + } +} diff --git a/lib/vertices_editor/view/vertices_editor_view.dart b/lib/vertices_editor/view/vertices_editor_view.dart new file mode 100644 index 0000000..43ac27e --- /dev/null +++ b/lib/vertices_editor/view/vertices_editor_view.dart @@ -0,0 +1,110 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/bloc/vertices_editor_cubit.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template vertices_editor_view} +/// The content of the `VerticesEditorDialog`: the canvas, the list of +/// vertices to type their coordinates, the selected vertex and the buttons +/// that apply or discard the changes. +/// {@endtemplate} +class VerticesEditorView extends StatelessWidget { + /// {@macro vertices_editor_view} + const new({required this.paint, super.key}); + + /// The paint the polygon is drawn with. + final ComponentPaint paint; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final state = context.watch().state; + final selectedIndex = state.selectedIndex; + + return Dialog( + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: 1080, + height: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 12), + child: Text('Edit vertices', style: theme.textTheme.titleLarge), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ClipRect(child: VerticesCanvas(paint: paint)), + ), + const VerticalDivider(width: 1), + const SizedBox(width: 280, child: VerticesList()), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 16, 16), + child: Row( + spacing: 8, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedIndex == null + ? '${state.vertices.length} vertices' + : _describe( + selectedIndex, + state.vertices[selectedIndex], + ), + ), + Text( + 'Drag to move · click + on an edge to add · ' + 'Delete to remove · type exact values on the right', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + tooltip: 'Delete vertex', + icon: const Icon(Icons.delete_outline), + onPressed: selectedIndex != null && state.canDelete + ? context + .read() + .selectedVertexDeleted + : null, + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context) + .pop(context.read().state.result), + child: const Text('Apply'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + static String _describe(int index, ComponentVertex vertex) { + return 'Vertex ${index + 1}: (${_number(vertex.x)}, ${_number(vertex.y)})'; + } + + static String _number(double value) { + return value == value.truncateToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); + } +} diff --git a/lib/vertices_editor/view/view.dart b/lib/vertices_editor/view/view.dart new file mode 100644 index 0000000..b7e6c36 --- /dev/null +++ b/lib/vertices_editor/view/view.dart @@ -0,0 +1,2 @@ +export 'vertices_editor_dialog.dart'; +export 'vertices_editor_view.dart'; diff --git a/lib/vertices_editor/widgets/vertices_canvas.dart b/lib/vertices_editor/widgets/vertices_canvas.dart new file mode 100644 index 0000000..5cf601d --- /dev/null +++ b/lib/vertices_editor/widgets/vertices_canvas.dart @@ -0,0 +1,186 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/bloc/vertices_editor_cubit.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/vertices_painter.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/vertices_view_transform.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template vertices_canvas} +/// The canvas of the vertices editor, where the user drags the vertices of +/// the polygon, adds vertices from the middle of its edges and deletes the +/// selected vertex with Delete or Backspace, or from its context menu. +/// {@endtemplate} +class VerticesCanvas extends StatefulWidget { + /// {@macro vertices_canvas} + const new({required this.paint, super.key}); + + /// The background of the canvas, the same as the preview's. + static const backgroundColor = Color(0xFF000000); + + /// The paint the polygon is drawn with. + final ComponentPaint paint; + + @override + State createState() => _VerticesCanvasState(); +} + +class _VerticesCanvasState extends State { + final _focusNode = FocusNode(debugLabel: 'VerticesCanvas'); + + // Computed once, so the polygon doesn't move while it is edited. + VerticesViewTransform? _transform; + int? _hoveredEdge; + int? _draggedIndex; + MouseCursor _cursor = MouseCursor.defer; + + VerticesEditorCubit get _cubit => context.read(); + + List get _vertices => _cubit.state.vertices; + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + void _onTapUp(TapUpDetails details, VerticesViewTransform transform) { + _focusNode.requestFocus(); + final point = details.localPosition; + final vertex = transform.vertexAt(_vertices, point); + if (vertex != null) { + _cubit.vertexSelected(vertex); + } else if (transform.midpointAt(_vertices, point) case final edge?) { + _insertAtMidpoint(edge, transform); + } else { + _cubit.vertexSelected(null); + } + } + + void _onPanStart(DragStartDetails details, VerticesViewTransform transform) { + _focusNode.requestFocus(); + final point = details.localPosition; + final vertex = transform.vertexAt(_vertices, point); + if (vertex != null) { + _cubit.vertexSelected(vertex); + _draggedIndex = vertex; + } else if (transform.midpointAt(_vertices, point) case final edge?) { + _insertAtMidpoint(edge, transform); + _draggedIndex = edge + 1; + } + } + + void _onPanUpdate( + DragUpdateDetails details, + VerticesViewTransform transform, + ) { + final index = _draggedIndex; + if (index == null) return; + final vertex = transform.toLocal(details.localPosition); + _cubit.vertexMoved(index, vertex.x, vertex.y); + } + + void _insertAtMidpoint(int edge, VerticesViewTransform transform) { + final vertex = transform.toLocal(transform.midpointOf(_vertices, edge)); + _cubit.vertexInserted(edge, vertex.x, vertex.y); + setState(() => _hoveredEdge = null); + } + + void _onHover(PointerHoverEvent event, VerticesViewTransform transform) { + final point = event.localPosition; + final edge = transform.edgeNear(_vertices, point); + final cursor = transform.vertexAt(_vertices, point) != null + ? SystemMouseCursors.move + : transform.midpointAt(_vertices, point) != null + ? SystemMouseCursors.click + : MouseCursor.defer; + if (edge != _hoveredEdge || cursor != _cursor) { + setState(() { + _hoveredEdge = edge; + _cursor = cursor; + }); + } + } + + Future _onSecondaryTapUp( + TapUpDetails details, + VerticesViewTransform transform, + ) async { + final vertex = transform.vertexAt(_vertices, details.localPosition); + if (vertex == null) return; + _cubit.vertexSelected(vertex); + final overlay = + Overlay.of(context).context.findRenderObject()! as RenderBox; + final position = details.globalPosition; + final delete = await showMenu( + context: context, + position: RelativeRect.fromRect( + position & Size.zero, + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + value: true, + enabled: _cubit.state.canDelete, + child: const Text('Delete vertex'), + ), + ], + ); + if (delete ?? false) _cubit.selectedVertexDeleted(); + } + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.delete): + _cubit.selectedVertexDeleted, + const SingleActivator(LogicalKeyboardKey.backspace): + _cubit.selectedVertexDeleted, + }, + child: Focus( + focusNode: _focusNode, + autofocus: true, + child: ColoredBox( + color: VerticesCanvas.backgroundColor, + child: LayoutBuilder( + builder: (context, constraints) { + final transform = _transform ??= VerticesViewTransform.fit( + state.initial.vertices, + constraints.biggest, + ); + return MouseRegion( + cursor: _cursor, + onHover: (event) => _onHover(event, transform), + onExit: (_) => setState(() => _hoveredEdge = null), + child: GestureDetector( + // Drags start where the pointer went down, on the handle. + dragStartBehavior: DragStartBehavior.down, + onTapUp: (details) => _onTapUp(details, transform), + onSecondaryTapUp: (details) => + _onSecondaryTapUp(details, transform), + onPanStart: (details) => _onPanStart(details, transform), + onPanUpdate: (details) => _onPanUpdate(details, transform), + onPanEnd: (_) => _draggedIndex = null, + onPanCancel: () => _draggedIndex = null, + child: CustomPaint( + size: constraints.biggest, + painter: VerticesPainter( + vertices: state.vertices, + transform: transform, + polygonPaint: widget.paint, + selectedIndex: state.selectedIndex, + hoveredEdge: _draggedIndex == null ? _hoveredEdge : null, + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/vertices_editor/widgets/vertices_list.dart b/lib/vertices_editor/widgets/vertices_list.dart new file mode 100644 index 0000000..03a39fc --- /dev/null +++ b/lib/vertices_editor/widgets/vertices_list.dart @@ -0,0 +1,88 @@ +import 'package:flame_component_editor/component_editor/widgets/property_editors/number_field.dart'; +import 'package:flame_component_editor/vertices_editor/bloc/vertices_editor_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template vertices_list} +/// The side bar of the vertices editor: a row per vertex, with fields to +/// type its exact coordinates and a button to delete it. +/// +/// Selecting a row, or focusing one of its fields, selects its vertex on +/// the canvas, and vice versa. +/// {@endtemplate} +class VerticesList extends StatelessWidget { + /// {@macro vertices_list} + const new({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final cubit = context.read(); + final theme = Theme.of(context); + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: state.vertices.length, + itemBuilder: (context, index) { + final vertex = state.vertices[index]; + final isSelected = index == state.selectedIndex; + // Fresh fields when vertices are added or removed, so no field + // keeps the text of the vertex that used to be at its index. + return Material( + key: ValueKey('${state.vertices.length}-$index'), + color: isSelected + ? theme.colorScheme.secondaryContainer + : Colors.transparent, + child: InkWell( + onTap: () => cubit.vertexSelected(index), + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onFocusChange: (focused) { + if (focused) cubit.vertexSelected(index); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 4, 8), + child: Row( + spacing: 8, + children: [ + SizedBox( + width: 24, + child: Text( + '${index + 1}', + style: theme.textTheme.labelLarge, + ), + ), + Expanded( + child: NumberField( + label: 'X', + value: vertex.x, + min: null, + onChanged: (x) => cubit.vertexMoved(index, x, vertex.y), + ), + ), + Expanded( + child: NumberField( + label: 'Y', + value: vertex.y, + min: null, + onChanged: (y) => cubit.vertexMoved(index, vertex.x, y), + ), + ), + IconButton( + tooltip: 'Delete vertex ${index + 1}', + icon: const Icon(Icons.remove_circle_outline), + onPressed: state.canDelete + ? () => cubit.vertexDeleted(index) + : null, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/vertices_editor/widgets/vertices_painter.dart b/lib/vertices_editor/widgets/vertices_painter.dart new file mode 100644 index 0000000..c87cad3 --- /dev/null +++ b/lib/vertices_editor/widgets/vertices_painter.dart @@ -0,0 +1,138 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/vertices_view_transform.dart'; +import 'package:flutter/material.dart'; + +/// {@template vertices_painter} +/// Draws the polygon edited in the vertices editor with its own paint, its +/// bounding box, the origin of its local coordinates, and the handles that +/// move its vertices and add new ones. +/// {@endtemplate} +class VerticesPainter extends CustomPainter { + /// {@macro vertices_painter} + const new({ + required this.vertices, + required this.transform, + required this.polygonPaint, + this.selectedIndex, + this.hoveredEdge, + }); + + /// The color of the handles, the guides and the selection. + static const accentColor = Color(0xFFFF00FF); + + /// The color of the guides. + static const guideColor = Color(0x66FFFFFF); + + /// The radius of the vertex handles. + static const handleRadius = 6.0; + + /// The vertices of the polygon. + final List vertices; + + /// Where the vertices are drawn. + final VerticesViewTransform transform; + + /// The paint of the polygon. + final ComponentPaint polygonPaint; + + /// The index of the selected vertex, if any. + final int? selectedIndex; + + /// The index of the edge (by its first vertex) under the pointer, whose + /// midpoint handle adds a vertex. + final int? hoveredEdge; + + @override + void paint(Canvas canvas, Size size) { + final guide = Paint() + ..color = guideColor + ..style = PaintingStyle.stroke; + + // The origin of the local coordinates. + final origin = transform.toCanvas(const ComponentVertex(0, 0)); + canvas + ..drawLine(Offset(origin.dx, 0), Offset(origin.dx, size.height), guide) + ..drawLine(Offset(0, origin.dy), Offset(size.width, origin.dy), guide); + + // The bounding box, which is the size of the component. + final bounds = VerticesViewTransform.boundsOf(vertices); + canvas.drawRect( + Rect.fromPoints( + transform.toCanvas(ComponentVertex(bounds.left, bounds.top)), + transform.toCanvas(ComponentVertex(bounds.right, bounds.bottom)), + ), + guide, + ); + + final path = Path() + ..addPolygon(vertices.map(transform.toCanvas).toList(), true); + canvas + ..drawPath( + path, + Paint() + ..color = Color(polygonPaint.color) + ..style = switch (polygonPaint.style) { + PaintStyle.fill => PaintingStyle.fill, + PaintStyle.stroke => PaintingStyle.stroke, + }, + ) + // The edges, which stay visible whatever the paint is. + ..drawPath( + path, + Paint() + ..color = accentColor.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke, + ); + + if (hoveredEdge case final edge? when edge < vertices.length) { + _paintAddHandle(canvas, transform.midpointOf(vertices, edge)); + } + + for (final (index, vertex) in vertices.indexed) { + final center = transform.toCanvas(vertex); + canvas + ..drawCircle( + center, + handleRadius, + Paint()..color = index == selectedIndex ? accentColor : Colors.white, + ) + ..drawCircle( + center, + handleRadius, + Paint() + ..color = accentColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + } + + void _paintAddHandle(Canvas canvas, Offset center) { + const arm = handleRadius - 2; + final stroke = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas + ..drawCircle(center, handleRadius, Paint()..color = accentColor) + ..drawLine( + center - const Offset(arm, 0), + center + const Offset(arm, 0), + stroke, + ) + ..drawLine( + center - const Offset(0, arm), + center + const Offset(0, arm), + stroke, + ); + } + + @override + bool shouldRepaint(VerticesPainter oldDelegate) { + return oldDelegate.vertices != vertices || + oldDelegate.transform != transform || + oldDelegate.polygonPaint != polygonPaint || + oldDelegate.selectedIndex != selectedIndex || + oldDelegate.hoveredEdge != hoveredEdge; + } +} diff --git a/lib/vertices_editor/widgets/vertices_view_transform.dart b/lib/vertices_editor/widgets/vertices_view_transform.dart new file mode 100644 index 0000000..aa69a6d --- /dev/null +++ b/lib/vertices_editor/widgets/vertices_view_transform.dart @@ -0,0 +1,127 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:component_repository/component_repository.dart'; + +/// {@template vertices_view_transform} +/// Maps the local coordinates of a polygon to the vertices canvas and +/// back, and finds the handles under a point of the canvas. +/// {@endtemplate} +class VerticesViewTransform { + /// {@macro vertices_view_transform} + const new({this.scale = 1, this.offset = Offset.zero}); + + /// A transform that centers [vertices] in a canvas of [size], scaled down + /// only if they don't fit within [padding] of its edges. + factory fit( + List vertices, + Size size, { + double padding = 48, + }) { + final bounds = boundsOf(vertices); + final available = Size( + math.max(size.width - padding * 2, 1), + math.max(size.height - padding * 2, 1), + ); + final scale = [ + 1.0, + if (bounds.width > 0) available.width / bounds.width, + if (bounds.height > 0) available.height / bounds.height, + ].reduce(math.min); + return VerticesViewTransform( + scale: scale, + offset: size.center(Offset.zero) - bounds.center * scale, + ); + } + + /// The radius around a handle within which the pointer hits it. + static const hitRadius = 10.0; + + /// The canvas pixels per local unit. + final double scale; + + /// Where the local origin is on the canvas. + final Offset offset; + + /// The bounding box of [vertices], in local coordinates. + static Rect boundsOf(List vertices) { + final xs = vertices.map((vertex) => vertex.x); + final ys = vertices.map((vertex) => vertex.y); + return Rect.fromLTRB( + xs.reduce(math.min), + ys.reduce(math.min), + xs.reduce(math.max), + ys.reduce(math.max), + ); + } + + /// Where [vertex] is on the canvas. + Offset toCanvas(ComponentVertex vertex) { + return Offset(vertex.x, vertex.y) * scale + offset; + } + + /// The vertex at [point] of the canvas, rounded to whole local units. + ComponentVertex toLocal(Offset point) { + final local = (point - offset) / scale; + return ComponentVertex(local.dx.roundToDouble(), local.dy.roundToDouble()); + } + + /// The middle of the edge that starts at the vertex at [index], on the + /// canvas. + Offset midpointOf(List vertices, int index) { + final from = toCanvas(vertices[index]); + final to = toCanvas(vertices[(index + 1) % vertices.length]); + return Offset.lerp(from, to, 0.5)!; + } + + /// The index of the vertex whose handle is at [point], if any. The + /// closest one wins. + int? vertexAt(List vertices, Offset point) { + return _closest( + vertices.length, + (index) => (toCanvas(vertices[index]) - point).distance, + ); + } + + /// The index of the edge (by its first vertex) whose midpoint handle is + /// at [point], if any. + int? midpointAt(List vertices, Offset point) { + return _closest( + vertices.length, + (index) => (midpointOf(vertices, index) - point).distance, + ); + } + + /// The index of the edge (by its first vertex) that passes near + /// [point], if any. + int? edgeNear(List vertices, Offset point) { + return _closest(vertices.length, (index) { + final from = toCanvas(vertices[index]); + final to = toCanvas(vertices[(index + 1) % vertices.length]); + return _distanceToSegment(point, from, to); + }); + } + + static int? _closest(int length, double Function(int index) distanceOf) { + int? closest; + var closestDistance = hitRadius; + for (var index = 0; index < length; index++) { + final distance = distanceOf(index); + if (distance <= closestDistance) { + closest = index; + closestDistance = distance; + } + } + return closest; + } + + static double _distanceToSegment(Offset point, Offset from, Offset to) { + final segment = to - from; + final lengthSquared = segment.distanceSquared; + if (lengthSquared == 0) return (point - from).distance; + final delta = point - from; + final t = ((delta.dx * segment.dx + delta.dy * segment.dy) / lengthSquared) + .clamp(0.0, 1.0); + return (point - (from + segment * t)).distance; + } +} diff --git a/lib/vertices_editor/widgets/widgets.dart b/lib/vertices_editor/widgets/widgets.dart new file mode 100644 index 0000000..b527160 --- /dev/null +++ b/lib/vertices_editor/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'vertices_canvas.dart'; +export 'vertices_list.dart'; +export 'vertices_painter.dart'; +export 'vertices_view_transform.dart'; diff --git a/packages/component_repository/lib/src/code_generation/component_code_generator.dart b/packages/component_repository/lib/src/code_generation/component_code_generator.dart index 42139d8..11be0f9 100644 --- a/packages/component_repository/lib/src/code_generation/component_code_generator.dart +++ b/packages/component_repository/lib/src/code_generation/component_code_generator.dart @@ -129,6 +129,7 @@ class ComponentCodeGenerator { RectangleComponent() => 'RectangleComponent', PositionComponent() => 'PositionComponent', CircleComponent() => 'CircleComponent', + PolygonComponent() => 'PolygonComponent', }; } @@ -139,6 +140,9 @@ class ComponentCodeGenerator { required bool withPosition, }) { return [ + // Positional, so it comes first. Not const: Flame changes the list. + if (component case final VerticesComponent c) + '[${c.vertices.vertices.map((v) => _vector(v.x, v.y)).join(', ')}]', if (component case final PositionedComponent c when withPosition) 'position: ${_vector(c.position.x, c.position.y)}', // Flame already defaults to no rotation and the top left anchor. diff --git a/packages/component_repository/lib/src/code_generation/dart_names.dart b/packages/component_repository/lib/src/code_generation/dart_names.dart index a3ca705..967b3bb 100644 --- a/packages/component_repository/lib/src/code_generation/dart_names.dart +++ b/packages/component_repository/lib/src/code_generation/dart_names.dart @@ -63,6 +63,7 @@ Set flameMemberNames(ComponentType type) { ComponentType.rectangle => _rectangleComponentMembers, ComponentType.position => _positionComponentMembers, ComponentType.circle => _circleComponentMembers, + ComponentType.polygon => _polygonComponentMembers, }; } @@ -197,9 +198,14 @@ const _positionComponentMembers = { }; /// Public members of Flame's `RectangleComponent` (Flame 2.0.0-dev.1), +/// including the inherited ones. It adds no public instance members to +/// `PolygonComponent`. +const Set _rectangleComponentMembers = _polygonComponentMembers; + +/// Public members of Flame's `PolygonComponent` (Flame 2.0.0-dev.1), /// including the inherited ones: the `PositionComponent` ones, plus those -/// of `HasPaint`, `ShapeComponent` and `PolygonComponent`. -const Set _rectangleComponentMembers = { +/// of `HasPaint` and `ShapeComponent`. +const Set _polygonComponentMembers = { ..._positionComponentMembers, 'deletePaint', 'getAlpha', diff --git a/packages/component_repository/lib/src/component_repository.dart b/packages/component_repository/lib/src/component_repository.dart index 4003d81..0cd4c15 100644 --- a/packages/component_repository/lib/src/component_repository.dart +++ b/packages/component_repository/lib/src/component_repository.dart @@ -101,7 +101,8 @@ class ComponentRepository { } /// Creates a component of [type] called [name], with default properties, - /// in the project at [projectPath]. + /// in the project at [projectPath]. Polygons start with the vertices of + /// [polygonShape]. /// /// Throws an [InvalidComponentNameException] if [name] is not a valid /// identifier or doesn't make a valid class name (such as `_1`), or a @@ -113,8 +114,9 @@ class ComponentRepository { Future createComponent( String projectPath, String name, - ComponentType type, - ) async { + ComponentType type, { + PolygonShape polygonShape = PolygonShape.triangle, + }) async { final className = dartClassName(name); if (!_validName.hasMatch(name) || !isDartIdentifier(className)) { throw InvalidComponentNameException(name); @@ -138,7 +140,7 @@ class ComponentRepository { } } - final component = Component.create(name, type); + final component = Component.create(name, type, polygonShape: polygonShape); await _write(projectPath, component); return component; } diff --git a/packages/component_repository/lib/src/models/component.dart b/packages/component_repository/lib/src/models/component.dart index cde86d1..4e7686c 100644 --- a/packages/component_repository/lib/src/models/component.dart +++ b/packages/component_repository/lib/src/models/component.dart @@ -2,6 +2,7 @@ import 'package:component_repository/component_repository.dart'; import 'package:equatable/equatable.dart'; part 'circle_component.dart'; +part 'polygon_component.dart'; part 'position_component.dart'; part 'rectangle_component.dart'; @@ -20,11 +21,21 @@ sealed class Component extends Equatable { const new({required this.name, this.children = const []}); /// Creates a component of [type] called [name] with default properties. - factory create(String name, ComponentType type) { + /// + /// Polygons start with the vertices of [polygonShape]. + factory create( + String name, + ComponentType type, { + PolygonShape polygonShape = PolygonShape.triangle, + }) { return switch (type) { ComponentType.rectangle => RectangleComponent(name: name), ComponentType.position => PositionComponent(name: name), ComponentType.circle => CircleComponent(name: name), + ComponentType.polygon => PolygonComponent( + name: name, + vertices: ComponentVertices(polygonShape.vertices), + ), }; } @@ -39,6 +50,7 @@ sealed class Component extends Equatable { ComponentType.rectangle => RectangleComponent._fromJson(json), ComponentType.position => PositionComponent._fromJson(json), ComponentType.circle => CircleComponent._fromJson(json), + ComponentType.polygon => PolygonComponent._fromJson(json), }; return component.copyWithChildren(_childrenFromJson(json['children'])); } diff --git a/packages/component_repository/lib/src/models/component_capabilities.dart b/packages/component_repository/lib/src/models/component_capabilities.dart index 1a6ef03..863b6ea 100644 --- a/packages/component_repository/lib/src/models/component_capabilities.dart +++ b/packages/component_repository/lib/src/models/component_capabilities.dart @@ -71,3 +71,15 @@ abstract interface class RadiusComponent { /// Returns a copy of this component with [radius] replaced. Component copyWithRadius(ComponentRadius radius); } + +/// A [Component] that has [ComponentVertices]. +/// +/// Implemented by polygon component types, whose Flame size is the +/// bounding box of the vertices. +abstract interface class VerticesComponent { + /// The vertices of the component. + ComponentVertices get vertices; + + /// Returns a copy of this component with [vertices] replaced. + Component copyWithVertices(ComponentVertices vertices); +} diff --git a/packages/component_repository/lib/src/models/component_type.dart b/packages/component_repository/lib/src/models/component_type.dart index e0f6127..d38fdce 100644 --- a/packages/component_repository/lib/src/models/component_type.dart +++ b/packages/component_repository/lib/src/models/component_type.dart @@ -10,7 +10,10 @@ enum ComponentType { position(key: 'position', label: 'Position'), /// A circle, backed by Flame's `CircleComponent`. - circle(key: 'circle', label: 'Circle'); + circle(key: 'circle', label: 'Circle'), + + /// A polygon, backed by Flame's `PolygonComponent`. + polygon(key: 'polygon', label: 'Polygon'); new({required this.key, required this.label}); diff --git a/packages/component_repository/lib/src/models/component_vertices.dart b/packages/component_repository/lib/src/models/component_vertices.dart new file mode 100644 index 0000000..856b5e1 --- /dev/null +++ b/packages/component_repository/lib/src/models/component_vertices.dart @@ -0,0 +1,108 @@ +import 'dart:math' as math; + +import 'package:equatable/equatable.dart'; + +/// {@template component_vertex} +/// A vertex of a [ComponentVertices], in the local coordinates of its +/// component. +/// {@endtemplate} +class ComponentVertex extends Equatable { + /// {@macro component_vertex} + const new(this.x, this.y); + + /// The horizontal coordinate. + final double x; + + /// The vertical coordinate. + final double y; + + /// Converts this vertex to its JSON representation. + Map toJson() => {'x': x, 'y': y}; + + @override + List get props => [x, y]; +} + +/// {@template component_vertices} +/// The vertices property shared by every polygon based component. +/// +/// The vertices are in the local coordinates of the component, and the +/// component size is their bounding box. Like Flame, a polygon needs at +/// least [minLength] vertices. +/// {@endtemplate} +class ComponentVertices extends Equatable { + /// {@macro component_vertices} + const new([this.vertices = defaultValue]); + + /// Creates a [ComponentVertices] from its JSON representation. + /// + /// Missing or invalid values, or fewer than [minLength] valid vertices, + /// fall back to [defaultValue]. + factory fromJson(Object? json) { + final map = json is Map ? json : const {}; + final list = map['vertices']; + final vertices = [ + if (list is List) + for (final vertex in list) + if (vertex case {'x': final num x, 'y': final num y}) + ComponentVertex(x.toDouble(), y.toDouble()), + ]; + return ComponentVertices( + vertices.length >= minLength ? vertices : defaultValue, + ); + } + + /// The fewest vertices a polygon can have. + static const minLength = 3; + + /// A triangle as big as a default rectangle. + static const triangle = [ + ComponentVertex(25, 0), + ComponentVertex(50, 50), + ComponentVertex(0, 50), + ]; + + /// A square as big as a default rectangle. + static const square = [ + ComponentVertex(0, 0), + ComponentVertex(50, 0), + ComponentVertex(50, 50), + ComponentVertex(0, 50), + ]; + + /// The default [vertices], a [triangle]. + static const List defaultValue = triangle; + + /// The vertices, in order. + final List vertices; + + /// The width and height of the bounding box of the [vertices]. + ({double width, double height}) get bounds { + final xs = vertices.map((vertex) => vertex.x); + final ys = vertices.map((vertex) => vertex.y); + return ( + width: xs.reduce(math.max) - xs.reduce(math.min), + height: ys.reduce(math.max) - ys.reduce(math.min), + ); + } + + /// Returns these vertices moved so that the top left corner of their + /// bounding box is at the origin, like Flame places them. + ComponentVertices normalized() { + final left = vertices.map((vertex) => vertex.x).reduce(math.min); + final top = vertices.map((vertex) => vertex.y).reduce(math.min); + if (left == 0 && top == 0) return this; + return ComponentVertices([ + for (final vertex in vertices) + ComponentVertex(vertex.x - left, vertex.y - top), + ]); + } + + /// Converts these vertices to their JSON representation. + Map toJson() => { + 'vertices': [for (final vertex in vertices) vertex.toJson()], + }; + + @override + List get props => [vertices]; +} diff --git a/packages/component_repository/lib/src/models/models.dart b/packages/component_repository/lib/src/models/models.dart index 7d586c9..9363487 100644 --- a/packages/component_repository/lib/src/models/models.dart +++ b/packages/component_repository/lib/src/models/models.dart @@ -8,4 +8,6 @@ export 'component_radius.dart'; export 'component_size.dart'; export 'component_tree.dart'; export 'component_type.dart'; +export 'component_vertices.dart'; export 'paint_style.dart'; +export 'polygon_shape.dart'; diff --git a/packages/component_repository/lib/src/models/polygon_component.dart b/packages/component_repository/lib/src/models/polygon_component.dart new file mode 100644 index 0000000..d1613a4 --- /dev/null +++ b/packages/component_repository/lib/src/models/polygon_component.dart @@ -0,0 +1,130 @@ +part of 'component.dart'; + +/// {@template polygon_component} +/// A polygon with a position, an anchor, an angle, vertices and a paint. +/// +/// Its size is the bounding box of its vertices. +/// {@endtemplate} +final class PolygonComponent extends Component + implements + PositionedComponent, + AnchoredComponent, + RotatedComponent, + VerticesComponent, + PaintedComponent { + /// {@macro polygon_component} + const new({ + required super.name, + this.position = const ComponentPosition(), + this.anchor = ComponentAnchor.topLeft, + this.angle = ComponentAngle.zero, + this.vertices = const ComponentVertices(), + this.paint = const ComponentPaint(), + super.children, + }); + + /// Reads the properties of a [PolygonComponent] from its JSON + /// representation. Use [Component.fromJson], which also reads the + /// children. + factory _fromJson(Map json) { + return PolygonComponent( + name: json['name'] as String, + position: ComponentPosition.fromJson(json['position']), + anchor: ComponentAnchor.fromJson(json['anchor']), + angle: ComponentAngle.fromJson(json['angle']), + vertices: ComponentVertices.fromJson(json['vertices']), + paint: ComponentPaint.fromJson(json['paint']), + ); + } + + @override + ComponentType get type => ComponentType.polygon; + + @override + final ComponentPosition position; + + @override + final ComponentAnchor anchor; + + @override + final ComponentAngle angle; + + @override + final ComponentVertices vertices; + + @override + final ComponentPaint paint; + + @override + PolygonComponent copyWithPosition(ComponentPosition position) { + return _copyWith(position: position); + } + + @override + PolygonComponent copyWithAnchor(ComponentAnchor anchor) { + return _copyWith(anchor: anchor); + } + + @override + PolygonComponent copyWithAngle(ComponentAngle angle) { + return _copyWith(angle: angle); + } + + @override + PolygonComponent copyWithVertices(ComponentVertices vertices) { + return _copyWith(vertices: vertices); + } + + @override + PolygonComponent copyWithPaint(ComponentPaint paint) { + return _copyWith(paint: paint); + } + + @override + PolygonComponent copyWithName(String name) => _copyWith(name: name); + + @override + PolygonComponent copyWithChildren(List children) { + return _copyWith(children: children); + } + + PolygonComponent _copyWith({ + String? name, + ComponentPosition? position, + ComponentAnchor? anchor, + ComponentAngle? angle, + ComponentVertices? vertices, + ComponentPaint? paint, + List? children, + }) { + return PolygonComponent( + name: name ?? this.name, + position: position ?? this.position, + anchor: anchor ?? this.anchor, + angle: angle ?? this.angle, + vertices: vertices ?? this.vertices, + paint: paint ?? this.paint, + children: children ?? this.children, + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'position': position.toJson(), + 'anchor': anchor.toJson(), + 'angle': angle.toJson(), + 'vertices': vertices.toJson(), + 'paint': paint.toJson(), + }; + + @override + List get props => [ + ...super.props, + position, + anchor, + angle, + vertices, + paint, + ]; +} diff --git a/packages/component_repository/lib/src/models/polygon_shape.dart b/packages/component_repository/lib/src/models/polygon_shape.dart new file mode 100644 index 0000000..06db548 --- /dev/null +++ b/packages/component_repository/lib/src/models/polygon_shape.dart @@ -0,0 +1,18 @@ +import 'package:component_repository/component_repository.dart'; + +/// The shapes a new polygon can start with. +enum PolygonShape { + /// Three vertices, see [ComponentVertices.triangle]. + triangle(label: 'Triangle', vertices: ComponentVertices.triangle), + + /// Four vertices, see [ComponentVertices.square]. + square(label: 'Square', vertices: ComponentVertices.square); + + new({required this.label, required this.vertices}); + + /// Human readable name. + final String label; + + /// The vertices of the shape. + final List vertices; +} diff --git a/packages/component_repository/test/src/code_generation/component_code_generator_test.dart b/packages/component_repository/test/src/code_generation/component_code_generator_test.dart index 868e55d..7a50d1e 100644 --- a/packages/component_repository/test/src/code_generation/component_code_generator_test.dart +++ b/packages/component_repository/test/src/code_generation/component_code_generator_test.dart @@ -207,6 +207,48 @@ class \$Ball extends CircleComponent { ); }); + test('generates polygons with their vertices', () { + expect( + generator.generate( + const PolygonComponent( + name: 'ship', + children: [ + PolygonComponent( + name: 'wing', + position: ComponentPosition(x: 1, y: 2), + vertices: ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(10, 0), + ComponentVertex(0, 2.5), + ]), + ), + ], + ), + ), + equals(''' +$_header/// The `ship` component. +class \$Ship extends PolygonComponent { + /// Creates a [\$Ship]. + \$Ship({super.position, super.angle, super.anchor, super.priority}) + : super([ + Vector2(25, 0), + Vector2(50, 50), + Vector2(0, 50), + ], paint: Paint()..color = const Color(0xFFFFFFFF)) { + add(wing); + } + + /// The `wing` child component. + final PolygonComponent wing = PolygonComponent( + [Vector2(0, 0), Vector2(10, 0), Vector2(0, 2.5)], + position: Vector2(1, 2), + paint: Paint()..color = const Color(0xFFFFFFFF), + ); +} +'''), + ); + }); + test('generates the angle of children, but not of the root', () { expect( generator.generate( diff --git a/packages/component_repository/test/src/code_generation/dart_names_test.dart b/packages/component_repository/test/src/code_generation/dart_names_test.dart index 5d72c16..e875ab4 100644 --- a/packages/component_repository/test/src/code_generation/dart_names_test.dart +++ b/packages/component_repository/test/src/code_generation/dart_names_test.dart @@ -77,6 +77,11 @@ void main() { containsAll(flameMemberNames(ComponentType.position)), ); expect(flameMemberNames(ComponentType.circle), isNot(contains('vertices'))); + expect( + flameMemberNames(ComponentType.polygon), + containsAll(['vertices', 'paint', 'refreshVertices', 'size']), + ); + expect(flameMemberNames(ComponentType.polygon), isNot(contains('radius'))); expect( flameMemberNames(ComponentType.rectangle), containsAll(flameMemberNames(ComponentType.position)), diff --git a/packages/component_repository/test/src/component_repository_test.dart b/packages/component_repository/test/src/component_repository_test.dart index 93a6f81..af92886 100644 --- a/packages/component_repository/test/src/component_repository_test.dart +++ b/packages/component_repository/test/src/component_repository_test.dart @@ -82,6 +82,27 @@ void main() { ).called(1); }); + test('creates polygons with the given shape', () async { + when(() => fileSystem.listFiles(directory, extension: '.json')) + .thenAnswer((_) async => []); + when(() => fileSystem.writeFile(any(), any())).thenAnswer((_) async {}); + + expect( + await repository.createComponent( + projectPath, + 'Player', + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + equals( + const PolygonComponent( + name: 'Player', + vertices: ComponentVertices(ComponentVertices.square), + ), + ), + ); + }); + for (final name in [ '', '1Player', diff --git a/packages/component_repository/test/src/models/component_test.dart b/packages/component_repository/test/src/models/component_test.dart index 553be30..6edbcbb 100644 --- a/packages/component_repository/test/src/models/component_test.dart +++ b/packages/component_repository/test/src/models/component_test.dart @@ -24,9 +24,39 @@ void main() { equals(const CircleComponent(name: 'Player')), ); }); + + test('creates a triangle polygon by default', () { + expect( + Component.create('Player', ComponentType.polygon), + equals(const PolygonComponent(name: 'Player')), + ); + }); + + test('creates a polygon with the given shape', () { + expect( + Component.create( + 'Player', + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + equals( + const PolygonComponent( + name: 'Player', + vertices: ComponentVertices(ComponentVertices.square), + ), + ), + ); + }); }); group('fromJson', () { + test('reads a polygon', () { + expect( + Component.fromJson(const {'name': 'Player', 'type': 'polygon'}), + equals(const PolygonComponent(name: 'Player')), + ); + }); + test('reads a circle', () { expect( Component.fromJson(const { diff --git a/packages/component_repository/test/src/models/component_type_test.dart b/packages/component_repository/test/src/models/component_type_test.dart index 8fbb691..248a435 100644 --- a/packages/component_repository/test/src/models/component_type_test.dart +++ b/packages/component_repository/test/src/models/component_type_test.dart @@ -10,6 +10,7 @@ void main() { ); expect(ComponentType.fromKey('position'), equals(ComponentType.position)); expect(ComponentType.fromKey('circle'), equals(ComponentType.circle)); + expect(ComponentType.fromKey('polygon'), equals(ComponentType.polygon)); }); test('fromKey returns null for unknown keys', () { diff --git a/packages/component_repository/test/src/models/component_vertices_test.dart b/packages/component_repository/test/src/models/component_vertices_test.dart new file mode 100644 index 0000000..585249d --- /dev/null +++ b/packages/component_repository/test/src/models/component_vertices_test.dart @@ -0,0 +1,107 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ComponentVertices, () { + test('defaults to a triangle', () { + expect( + const ComponentVertices().vertices, + equals(ComponentVertices.triangle), + ); + }); + + test('round-trips through JSON', () { + const vertices = ComponentVertices([ + ComponentVertex(1, 2), + ComponentVertex(3.5, 4), + ComponentVertex(-5, 6), + ]); + + expect(ComponentVertices.fromJson(vertices.toJson()), equals(vertices)); + }); + + test('fromJson skips invalid vertices', () { + expect( + ComponentVertices.fromJson(const { + 'vertices': [ + {'x': 0, 'y': 0}, + {'x': 'a', 'y': 0}, + 'invalid', + {'x': 1, 'y': 0}, + {'x': 1, 'y': 1}, + ], + }), + equals( + const ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(1, 0), + ComponentVertex(1, 1), + ]), + ), + ); + }); + + test('fromJson falls back to the default with fewer than 3 vertices', () { + expect( + ComponentVertices.fromJson(const { + 'vertices': [ + {'x': 0, 'y': 0}, + {'x': 1, 'y': 0}, + ], + }), + equals(const ComponentVertices()), + ); + expect( + ComponentVertices.fromJson(null), + equals(const ComponentVertices()), + ); + expect( + ComponentVertices.fromJson(const {'vertices': 'invalid'}), + equals(const ComponentVertices()), + ); + }); + + test('bounds is the size of the bounding box', () { + const vertices = ComponentVertices([ + ComponentVertex(-10, 5), + ComponentVertex(20, 0), + ComponentVertex(0, 40), + ]); + + expect(vertices.bounds, equals((width: 30.0, height: 40.0))); + }); + + test('normalized moves the bounding box to the origin', () { + const vertices = ComponentVertices([ + ComponentVertex(-10, 5), + ComponentVertex(20, 10), + ComponentVertex(0, 40), + ]); + + expect( + vertices.normalized(), + equals( + const ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(30, 5), + ComponentVertex(10, 35), + ]), + ), + ); + expect( + const ComponentVertices().normalized(), + same(const ComponentVertices()), + ); + }); + }); + + group(PolygonShape, () { + test('has the vertices of its shape', () { + expect( + PolygonShape.triangle.vertices, + equals(ComponentVertices.triangle), + ); + expect(PolygonShape.square.vertices, hasLength(4)); + }); + }); +} diff --git a/packages/component_repository/test/src/models/polygon_component_test.dart b/packages/component_repository/test/src/models/polygon_component_test.dart new file mode 100644 index 0000000..a4630f5 --- /dev/null +++ b/packages/component_repository/test/src/models/polygon_component_test.dart @@ -0,0 +1,167 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(PolygonComponent, () { + const component = PolygonComponent( + name: 'Player', + position: ComponentPosition(x: 1, y: 2), + anchor: ComponentAnchor.center, + angle: ComponentAngle(radians: 1.5), + vertices: ComponentVertices(ComponentVertices.square), + paint: ComponentPaint(color: 0xFF00FF00, style: PaintStyle.stroke), + children: [RectangleComponent(name: 'rectangle1')], + ); + + test('supports value equality', () { + expect( + const PolygonComponent(name: 'Player'), + equals(const PolygonComponent(name: 'Player')), + ); + expect(component, isNot(equals(const PolygonComponent(name: 'Player')))); + }); + + test('is a polygon with a position, vertices and a paint', () { + expect(component.type, equals(ComponentType.polygon)); + expect(component, isA()); + expect(component, isA()); + expect(component, isA()); + expect(component, isA()); + expect(component, isNot(isA())); + expect(component, isNot(isA())); + expect(component, isA()); + }); + + test('round-trips through JSON', () { + expect(Component.fromJson(component.toJson()), equals(component)); + }); + + test('toJson includes the type, properties and children', () { + expect( + component.toJson(), + equals({ + 'name': 'Player', + 'type': 'polygon', + 'children': [ + { + 'name': 'rectangle1', + 'type': 'rectangle', + 'children': [], + 'position': {'x': 0.0, 'y': 0.0}, + 'anchor': {'x': 0.0, 'y': 0.0}, + 'angle': {'radians': 0.0}, + 'size': {'width': 50.0, 'height': 50.0}, + 'paint': {'color': 0xFFFFFFFF, 'style': 'fill'}, + }, + ], + 'position': {'x': 1.0, 'y': 2.0}, + 'anchor': {'x': 0.5, 'y': 0.5}, + 'angle': {'radians': 1.5}, + 'vertices': { + 'vertices': [ + {'x': 0.0, 'y': 0.0}, + {'x': 50.0, 'y': 0.0}, + {'x': 50.0, 'y': 50.0}, + {'x': 0.0, 'y': 50.0}, + ], + }, + 'paint': {'color': 0xFF00FF00, 'style': 'stroke'}, + }), + ); + }); + + test('copyWithPosition replaces only the position', () { + const position = ComponentPosition(x: 5, y: 6); + final copy = component.copyWithPosition(position); + + expect(copy.position, equals(position)); + expect( + copy.toJson()..remove('position'), + equals(component.toJson()..remove('position')), + ); + }); + + test('copyWithAnchor replaces only the anchor', () { + const anchor = ComponentAnchor(x: 0.25, y: 1); + final copy = component.copyWithAnchor(anchor); + + expect(copy.anchor, equals(anchor)); + expect( + copy.toJson()..remove('anchor'), + equals(component.toJson()..remove('anchor')), + ); + }); + + test('is anchored at its top left corner when saved without an ' + 'anchor', () { + final json = component.toJson()..remove('anchor'); + + expect( + (Component.fromJson(json) as AnchoredComponent).anchor, + equals(ComponentAnchor.topLeft), + ); + }); + + test('copyWithAngle replaces only the angle', () { + const angle = ComponentAngle(radians: 3); + final copy = component.copyWithAngle(angle); + + expect(copy.angle, equals(angle)); + expect( + copy.toJson()..remove('angle'), + equals(component.toJson()..remove('angle')), + ); + }); + + test('is not rotated when saved without an angle', () { + final json = component.toJson()..remove('angle'); + + expect( + (Component.fromJson(json) as RotatedComponent).angle, + equals(ComponentAngle.zero), + ); + }); + + test('copyWithVertices replaces only the vertices', () { + const vertices = ComponentVertices(); + final copy = component.copyWithVertices(vertices); + + expect(copy.vertices, equals(vertices)); + expect( + copy.toJson()..remove('vertices'), + equals(component.toJson()..remove('vertices')), + ); + }); + + test('copyWithPaint replaces only the paint', () { + const paint = ComponentPaint(color: 0xFF0000FF); + final copy = component.copyWithPaint(paint); + + expect(copy.paint, equals(paint)); + expect( + copy.toJson()..remove('paint'), + equals(component.toJson()..remove('paint')), + ); + }); + + test('copyWithName replaces only the name', () { + final copy = component.copyWithName('Enemy'); + + expect(copy.name, equals('Enemy')); + expect( + copy.toJson()..remove('name'), + equals(component.toJson()..remove('name')), + ); + }); + + test('copyWithChildren replaces only the children', () { + final copy = component.copyWithChildren(const []); + + expect(copy.children, isEmpty); + expect( + copy.toJson()..remove('children'), + equals(component.toJson()..remove('children')), + ); + }); + }); +} diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index 02b41a6..e130be9 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -100,6 +100,35 @@ void main() { ], ); + group('ComponentEditorVerticesChanged', () { + const vertices = ComponentVertices(ComponentVertices.square); + const polygon = PolygonComponent(name: 'Player'); + + blocTest( + 'updates the vertices of a polygon', + build: () => ComponentEditorBloc( + project: project, + component: polygon, + componentRepository: componentRepository, + projectRepository: projectRepository, + ), + act: (bloc) => bloc.add(const ComponentEditorVerticesChanged(vertices)), + expect: () => const [ + ComponentEditorState( + component: PolygonComponent(name: 'Player', vertices: vertices), + savedComponent: polygon, + ), + ], + ); + + blocTest( + 'ignores components without vertices', + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorVerticesChanged(vertices)), + expect: () => const [], + ); + }); + group('ComponentEditorRadiusChanged', () { const radius = ComponentRadius(radius: 10); const circle = CircleComponent(name: 'Player'); @@ -376,6 +405,27 @@ void main() { ], ); + blocTest( + 'ComponentEditorChildAdded adds polygons with the picked shape', + build: buildBloc, + act: (bloc) => bloc.add( + const ComponentEditorChildAdded( + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + ), + expect: () => [ + isA().having( + (s) => s.selectedNode, + 'selectedNode', + const PolygonComponent( + name: 'polygon1', + vertices: ComponentVertices(ComponentVertices.square), + ), + ), + ], + ); + blocTest( 'ComponentEditorChildAdded skips names whose class is taken', build: buildBloc, diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index b6c9d70..3c6208c 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -192,6 +192,18 @@ void main() { ).called(1); }); + testWidgets('edits the vertices of polygons', (tester) async { + const polygon = PolygonComponent(name: 'Player'); + when(() => bloc.state).thenReturn( + const ComponentEditorState(component: polygon, savedComponent: polygon), + ); + await tester.pumpApp(buildSubject()); + + expect(find.byType(SizeEditor), findsNothing); + expect(find.byType(VerticesEditor), findsOneWidget); + expect(find.byType(PaintEditor), findsOneWidget); + }); + testWidgets('edits the radius of circles', (tester) async { const circle = CircleComponent(name: 'Player'); when(() => bloc.state).thenReturn( @@ -291,6 +303,26 @@ void main() { ).called(1); }); + testWidgets('adds polygons with the picked shape', (tester) async { + stubTree(); + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byTooltip('Add child')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Polygon (triangle)'), findsOneWidget); + await tester.tap(find.text('Polygon (square)')); + + verify( + () => bloc.add( + const ComponentEditorChildAdded( + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + ), + ).called(1); + }); + testWidgets('cannot remove the root', (tester) async { stubTree(); await tester.pumpApp(buildSubject()); diff --git a/test/component_editor/widgets/preview/component_preview_game_test.dart b/test/component_editor/widgets/preview/component_preview_game_test.dart index 5347c9a..12793ce 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -218,6 +218,54 @@ void main() { ); }); + testWithGame( + 'renders polygons with their vertices', + () => ComponentPreviewGame( + component: const PolygonComponent( + name: 'Player', + children: [ + PolygonComponent( + name: 'polygon1', + vertices: ComponentVertices(ComponentVertices.square), + ), + ], + ), + highlightedPath: [0], + ), + (game) async { + await game.ready(); + + final preview = game.preview as flame.PolygonComponent; + expect(preview.vertices, hasLength(3)); + expect(preview.size, equals(flame.Vector2(50, 50))); + final child = flameNodeAt(preview, [0]) as flame.PolygonComponent; + expect(child.vertices, hasLength(4)); + expect(game.highlight.target, same(child)); + }, + ); + + testWithGame( + 'rebuilds polygons whose vertices changed', + () => ComponentPreviewGame( + component: const PolygonComponent(name: 'Player'), + ), + (game) async { + await game.ready(); + final preview = game.preview; + + game.show( + const PolygonComponent( + name: 'Player', + vertices: ComponentVertices(ComponentVertices.square), + ), + ); + await game.ready(); + + expect(game.preview, isNot(same(preview))); + expect((game.preview as flame.PolygonComponent).vertices, hasLength(4)); + }, + ); + group('highlight', () { const child = RectangleComponent(name: 'rectangle1'); final tree = component.copyWithChildren(const [child]); @@ -298,6 +346,23 @@ void main() { haveSameShape(component, component.copyWithChildren(const [child])), isFalse, ); + const polygon = PolygonComponent(name: 'Player'); + expect( + haveSameShape( + polygon, + polygon.copyWithPaint(const ComponentPaint(color: 0xFF000000)), + ), + isTrue, + ); + expect( + haveSameShape( + polygon, + polygon.copyWithVertices( + const ComponentVertices(ComponentVertices.square), + ), + ), + isFalse, + ); }); testWithGame( diff --git a/test/component_editor/widgets/property_editors/vertices_editor_test.dart b/test/component_editor/widgets/property_editors/vertices_editor_test.dart new file mode 100644 index 0000000..8125ac6 --- /dev/null +++ b/test/component_editor/widgets/property_editors/vertices_editor_test.dart @@ -0,0 +1,79 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; +import 'package:flame_component_editor/vertices_editor/vertices_editor.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(VerticesEditor, () { + Future> pumpEditor(WidgetTester tester) async { + final changes = []; + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VerticesEditor( + value: const ComponentVertices(), + onChanged: changes.add, + ), + ), + ), + ); + return changes; + } + + testWidgets('shows the number of vertices and the size', (tester) async { + await pumpEditor(tester); + + expect(find.text('3 vertices, 50 × 50'), findsOneWidget); + }); + + testWidgets('edits the vertices in the dialog', (tester) async { + final changes = await pumpEditor(tester); + + await tester.tap(find.text('Edit vertices')); + await tester.pumpAndSettle(); + expect(find.byType(VerticesEditorDialog), findsOneWidget); + + // Adds a vertex in the middle of the first edge, then applies. + tester + .element(find.byType(VerticesCanvas)) + .read() + .vertexInserted(0, 40, 20); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(changes, hasLength(1)); + expect(changes.single.vertices, hasLength(4)); + }); + + testWidgets('keeps the vertices when the dialog is cancelled', ( + tester, + ) async { + final changes = await pumpEditor(tester); + + await tester.tap(find.text('Edit vertices')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(VerticesEditorDialog), findsNothing); + expect(changes, isEmpty); + }); + + testWidgets('does not report unchanged vertices', (tester) async { + final changes = await pumpEditor(tester); + + await tester.tap(find.text('Edit vertices')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(changes, isEmpty); + }); + }); +} diff --git a/test/project/bloc/project_bloc_test.dart b/test/project/bloc/project_bloc_test.dart index c9d72f6..2af7916 100644 --- a/test/project/bloc/project_bloc_test.dart +++ b/test/project/bloc/project_bloc_test.dart @@ -68,6 +68,38 @@ void main() { }); group(ProjectComponentCreateRequested, () { + blocTest( + 'creates polygons with the requested shape', + setUp: () { + when( + () => componentRepository.createComponent( + project.path, + 'Player', + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + ).thenAnswer((_) async => const PolygonComponent(name: 'Player')); + when(() => componentRepository.getComponents(project.path)) + .thenAnswer((_) async => []); + }, + build: buildBloc, + act: (bloc) => bloc.add( + const ProjectComponentCreateRequested( + 'Player', + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + ), + verify: (_) => verify( + () => componentRepository.createComponent( + project.path, + 'Player', + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + ).called(1), + ); + blocTest( 'emits created with the new component and reloaded components', setUp: () { diff --git a/test/project/view/project_page_test.dart b/test/project/view/project_page_test.dart index 65eab98..42ca42f 100644 --- a/test/project/view/project_page_test.dart +++ b/test/project/view/project_page_test.dart @@ -273,6 +273,35 @@ void main() { expect(find.text('Rectangle'), findsWidgets); }); + testWidgets('asks for the shape of polygons', (tester) async { + await tester.pumpApp(buildSubject()); + await tester.tap(find.text('Add component')); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu), findsNothing); + + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Ship'); + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Polygon').last); + await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Square').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + verify( + () => projectBloc.add( + const ProjectComponentCreateRequested( + 'Ship', + ComponentType.polygon, + polygonShape: PolygonShape.square, + ), + ), + ).called(1); + }); + testWidgets('disables create while the name is empty', (tester) async { await tester.pumpApp(buildSubject()); await tester.tap(find.text('Add component')); diff --git a/test/vertices_editor/bloc/vertices_editor_cubit_test.dart b/test/vertices_editor/bloc/vertices_editor_cubit_test.dart new file mode 100644 index 0000000..445aae3 --- /dev/null +++ b/test/vertices_editor/bloc/vertices_editor_cubit_test.dart @@ -0,0 +1,152 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/vertices_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(VerticesEditorCubit, () { + const triangle = ComponentVertices(); + const square = ComponentVertices(ComponentVertices.square); + + test('starts editing the given vertices, with nothing selected', () { + final state = VerticesEditorCubit(square).state; + + expect(state.vertices, equals(square.vertices)); + expect(state.selectedIndex, isNull); + expect(state.hasChanges, isFalse); + expect(state.canDelete, isTrue); + }); + + test('cannot delete vertices of a triangle', () { + expect(VerticesEditorCubit(triangle).state.canDelete, isFalse); + }); + + blocTest( + 'vertexSelected selects and unselects vertices', + build: () => VerticesEditorCubit(triangle), + act: (cubit) => cubit + ..vertexSelected(1) + ..vertexSelected(null), + expect: () => [ + VerticesEditorState(initial: triangle, selectedIndex: 1), + VerticesEditorState(initial: triangle), + ], + ); + + blocTest( + 'vertexMoved moves a vertex', + build: () => VerticesEditorCubit(triangle), + act: (cubit) => cubit.vertexMoved(1, 60, 70), + expect: () => [ + VerticesEditorState( + initial: triangle, + vertices: const [ + ComponentVertex(25, 0), + ComponentVertex(60, 70), + ComponentVertex(0, 50), + ], + ), + ], + verify: (cubit) => expect(cubit.state.hasChanges, isTrue), + ); + + blocTest( + 'vertexInserted adds a vertex after the start of the edge and ' + 'selects it', + build: () => VerticesEditorCubit(triangle), + act: (cubit) => cubit.vertexInserted(2, 10, 20), + expect: () => [ + VerticesEditorState( + initial: triangle, + vertices: const [ + ...ComponentVertices.triangle, + ComponentVertex(10, 20), + ], + selectedIndex: 3, + ), + ], + ); + + blocTest( + 'selectedVertexDeleted removes the selected vertex', + build: () => VerticesEditorCubit(square), + seed: () => VerticesEditorState(initial: square, selectedIndex: 0), + act: (cubit) => cubit.selectedVertexDeleted(), + expect: () => [ + VerticesEditorState( + initial: square, + vertices: ComponentVertices.square.sublist(1), + ), + ], + ); + + blocTest( + 'selectedVertexDeleted keeps at least 3 vertices', + build: () => VerticesEditorCubit(triangle), + seed: () => VerticesEditorState(initial: triangle, selectedIndex: 0), + act: (cubit) => cubit.selectedVertexDeleted(), + expect: () => const [], + ); + + blocTest( + 'vertexDeleted keeps the selection on the same vertex', + build: () => VerticesEditorCubit(square), + seed: () => VerticesEditorState(initial: square, selectedIndex: 2), + act: (cubit) => cubit + ..vertexDeleted(0) + ..vertexDeleted(2), + expect: () => [ + VerticesEditorState( + initial: square, + vertices: ComponentVertices.square.sublist(1), + selectedIndex: 1, + ), + ], + ); + + blocTest( + 'vertexDeleted keeps vertices after the selected one selected', + build: () => VerticesEditorCubit(square), + seed: () => VerticesEditorState(initial: square, selectedIndex: 0), + act: (cubit) => cubit.vertexDeleted(3), + expect: () => [ + VerticesEditorState( + initial: square, + vertices: ComponentVertices.square.sublist(0, 3), + selectedIndex: 0, + ), + ], + ); + + blocTest( + 'selectedVertexDeleted does nothing without a selection', + build: () => VerticesEditorCubit(square), + act: (cubit) => cubit.selectedVertexDeleted(), + expect: () => const [], + ); + + test('result places the vertices like Flame does', () { + final cubit = VerticesEditorCubit(triangle)..vertexMoved(0, -10, -20); + + expect( + cubit.state.result, + equals( + const ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(60, 70), + ComponentVertex(10, 70), + ]), + ), + ); + }); + + test('moving the whole shape is not a change', () { + final cubit = VerticesEditorCubit(triangle); + for (final (index, vertex) in triangle.vertices.indexed) { + cubit.vertexMoved(index, vertex.x + 5, vertex.y + 5); + } + + expect(cubit.state.hasChanges, isFalse); + }); + }); +} diff --git a/test/vertices_editor/view/vertices_editor_dialog_test.dart b/test/vertices_editor/view/vertices_editor_dialog_test.dart new file mode 100644 index 0000000..1d916cf --- /dev/null +++ b/test/vertices_editor/view/vertices_editor_dialog_test.dart @@ -0,0 +1,302 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/vertices_editor.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/widgets.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(VerticesEditorDialog, () { + const square = ComponentVertices(ComponentVertices.square); + + late ComponentVertices? result; + + Future openDialog( + WidgetTester tester, { + ComponentVertices vertices = square, + }) async { + result = null; + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await VerticesEditorDialog.show( + context, + vertices: vertices, + paint: const ComponentPaint(style: PaintStyle.stroke), + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + Finder canvas() => find.descendant( + of: find.byType(VerticesCanvas), + matching: find.byType(CustomPaint), + ); + + /// Where the local [vertex] is on the screen. + Offset onScreen( + WidgetTester tester, + ComponentVertex vertex, { + List initial = ComponentVertices.square, + }) { + final transform = VerticesViewTransform.fit( + initial, + tester.getSize(canvas()), + ); + return tester.getTopLeft(canvas()) + transform.toCanvas(vertex); + } + + Future apply(WidgetTester tester) async { + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + } + + testWidgets('shows the number of vertices', (tester) async { + await openDialog(tester); + + expect(find.text('Edit vertices'), findsOneWidget); + expect(find.text('4 vertices'), findsOneWidget); + }); + + testWidgets('returns null when cancelled', (tester) async { + await openDialog(tester); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(VerticesEditorDialog), findsNothing); + expect(result, isNull); + }); + + testWidgets('selects a vertex when it is tapped', (tester) async { + await openDialog(tester); + + await tester.tapAt(onScreen(tester, const ComponentVertex(50, 0))); + await tester.pump(); + expect(find.text('Vertex 2: (50, 0)'), findsOneWidget); + + await tester.tapAt(onScreen(tester, const ComponentVertex(25, 25))); + await tester.pump(); + expect(find.text('4 vertices'), findsOneWidget); + }); + + testWidgets('moves a vertex when it is dragged', (tester) async { + await openDialog(tester); + + await tester.dragFrom( + onScreen(tester, const ComponentVertex(50, 50)), + const Offset(30, 10), + ); + await tester.pump(); + await apply(tester); + + expect( + result, + equals( + const ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(50, 0), + ComponentVertex(80, 60), + ComponentVertex(0, 50), + ]), + ), + ); + }); + + testWidgets('adds a vertex when the middle of an edge is tapped', ( + tester, + ) async { + await openDialog(tester); + + await tester.tapAt(onScreen(tester, const ComponentVertex(25, 0))); + await tester.pump(); + expect(find.text('Vertex 2: (25, 0)'), findsOneWidget); + await apply(tester); + + expect(result!.vertices, hasLength(5)); + expect(result!.vertices[1], equals(const ComponentVertex(25, 0))); + }); + + testWidgets('adds and moves a vertex dragged from the middle of an ' + 'edge', (tester) async { + await openDialog(tester); + + await tester.dragFrom( + onScreen(tester, const ComponentVertex(25, 0)), + const Offset(0, -20), + ); + await tester.pump(); + await apply(tester); + + expect(result!.vertices, hasLength(5)); + expect(result!.vertices[1], equals(const ComponentVertex(25, 0))); + expect(result!.vertices[0], equals(const ComponentVertex(0, 20))); + }); + + testWidgets('shows the add handle of hovered edges', (tester) async { + await openDialog(tester); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: Offset.zero); + await gesture.moveTo(onScreen(tester, const ComponentVertex(10, 0))); + await tester.pump(); + + final painter = tester.widget(canvas()).painter!; + expect((painter as VerticesPainter).hoveredEdge, equals(0)); + + await gesture.moveTo(onScreen(tester, const ComponentVertex(0, 0))); + await tester.pump(); + await gesture.moveTo(Offset.zero); + await tester.pump(); + }); + + testWidgets('deletes the selected vertex with the keyboard', ( + tester, + ) async { + await openDialog(tester); + + await tester.tapAt(onScreen(tester, const ComponentVertex(50, 0))); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pump(); + expect(find.text('3 vertices'), findsOneWidget); + + // A triangle can't lose a vertex. + await tester.tapAt(onScreen(tester, const ComponentVertex(0, 0))); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + final delete = tester.widget( + find.widgetWithIcon(IconButton, Icons.delete_outline), + ); + expect(delete.onPressed, isNull); + await apply(tester); + + expect( + result, + equals( + const ComponentVertices([ + ComponentVertex(0, 0), + ComponentVertex(50, 50), + ComponentVertex(0, 50), + ]), + ), + ); + }); + + testWidgets('deletes the selected vertex with the button', (tester) async { + await openDialog(tester); + + await tester.tapAt(onScreen(tester, const ComponentVertex(0, 0))); + await tester.pump(); + await tester.tap(find.byTooltip('Delete vertex')); + await tester.pump(); + + expect(find.text('3 vertices'), findsOneWidget); + }); + + group('side bar', () { + Finder field(int index, String label) => find.descendant( + of: find.byKey(ValueKey('4-$index')), + matching: find.widgetWithText(TextField, label), + ); + + testWidgets('lists the coordinates of every vertex', (tester) async { + await openDialog(tester); + + expect(find.byType(VerticesList), findsOneWidget); + expect(find.widgetWithText(TextField, 'X'), findsNWidgets(4)); + expect( + tester.widget(field(1, 'X')).controller!.text, + equals('50'), + ); + }); + + testWidgets('moves a vertex to the typed coordinates', (tester) async { + await openDialog(tester); + + await tester.enterText(field(2, 'X'), '62.5'); + await tester.enterText(field(2, 'Y'), '-10'); + await tester.pump(); + expect(find.text('Vertex 3: (62.5, -10)'), findsOneWidget); + await apply(tester); + + expect( + result, + equals( + const ComponentVertices([ + ComponentVertex(0, 10), + ComponentVertex(50, 10), + ComponentVertex(62.5, 0), + ComponentVertex(0, 60), + ]), + ), + ); + }); + + testWidgets('selects the vertex of the tapped row', (tester) async { + await openDialog(tester); + + await tester.tap(find.text('4')); + await tester.pump(); + + expect(find.text('Vertex 4: (0, 50)'), findsOneWidget); + }); + + testWidgets('follows the vertices dragged on the canvas', (tester) async { + await openDialog(tester); + + await tester.dragFrom( + onScreen(tester, const ComponentVertex(50, 50)), + const Offset(30, 10), + ); + await tester.pump(); + + expect( + tester.widget(field(2, 'X')).controller!.text, + equals('80'), + ); + }); + + testWidgets('deletes a vertex from its row', (tester) async { + await openDialog(tester); + + await tester.tap(find.byTooltip('Delete vertex 1')); + await tester.pump(); + + expect(find.text('3 vertices'), findsOneWidget); + final delete = tester.widget( + find.widgetWithIcon(IconButton, Icons.remove_circle_outline).first, + ); + expect(delete.onPressed, isNull); + }); + }); + + testWidgets('deletes a vertex from its context menu', (tester) async { + await openDialog(tester); + + await tester.tapAt( + onScreen(tester, const ComponentVertex(0, 50)), + buttons: kSecondaryButton, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete vertex').last); + await tester.pumpAndSettle(); + + expect(find.text('3 vertices'), findsOneWidget); + }); + }); +} diff --git a/test/vertices_editor/widgets/vertices_view_transform_test.dart b/test/vertices_editor/widgets/vertices_view_transform_test.dart new file mode 100644 index 0000000..d91ec37 --- /dev/null +++ b/test/vertices_editor/widgets/vertices_view_transform_test.dart @@ -0,0 +1,75 @@ +import 'package:component_repository/component_repository.dart'; +import 'package:flame_component_editor/vertices_editor/widgets/widgets.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(VerticesViewTransform, () { + const square = ComponentVertices.square; + + test('fit centers vertices that fit without scaling them', () { + final transform = VerticesViewTransform.fit(square, const Size(400, 300)); + + expect(transform.scale, equals(1)); + expect( + transform.toCanvas(const ComponentVertex(25, 25)), + equals(const Offset(200, 150)), + ); + }); + + test('fit scales down vertices that do not fit', () { + final transform = VerticesViewTransform.fit(const [ + ComponentVertex(0, 0), + ComponentVertex(1000, 0), + ComponentVertex(0, 100), + ], const Size(596, 400)); + + expect(transform.scale, equals(0.5)); + expect( + transform.toCanvas(const ComponentVertex(500, 50)), + equals(const Offset(298, 200)), + ); + }); + + test('toLocal is the inverse of toCanvas, rounded', () { + const transform = VerticesViewTransform(scale: 2, offset: Offset(10, 20)); + + expect( + transform.toCanvas(const ComponentVertex(5, 5)), + equals(const Offset(20, 30)), + ); + expect( + transform.toLocal(const Offset(20.8, 29.2)), + equals(const ComponentVertex(5, 5)), + ); + }); + + group('hit testing', () { + const transform = VerticesViewTransform(); + + test('vertexAt finds the closest handle within the hit radius', () { + expect(transform.vertexAt(square, const Offset(48, 3)), equals(1)); + expect(transform.vertexAt(square, const Offset(25, 25)), isNull); + }); + + test('midpointAt finds the middle of edges', () { + expect(transform.midpointOf(square, 3), equals(const Offset(0, 25))); + expect(transform.midpointAt(square, const Offset(2, 24)), equals(3)); + expect(transform.midpointAt(square, Offset.zero), isNull); + }); + + test('edgeNear finds edges near the point', () { + expect(transform.edgeNear(square, const Offset(40, 52)), equals(2)); + expect(transform.edgeNear(square, const Offset(25, 25)), isNull); + expect( + transform.edgeNear(const [ + ComponentVertex(0, 0), + ComponentVertex(0, 0), + ComponentVertex(50, 50), + ], const Offset(-3, -4)), + isNotNull, + ); + }); + }); + }); +}