Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"cubit",
"cubits",
"flame",
"lerp",
"LTRB",
"LTWH",
"mocktail",
"Zanardo"
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ three pass.
`file_system_client`, `project_repository`, `component_repository`
(components stored in `<project>/.flame_editor/components/<name>.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`:
Expand Down
34 changes: 28 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<project>/.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 `<project>/.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 `<project>/.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
Expand All @@ -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` |
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions lib/component_editor/bloc/component_editor_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class ComponentEditorBloc
on<ComponentEditorAngleChanged>(_onAngleChanged);
on<ComponentEditorSizeChanged>(_onSizeChanged);
on<ComponentEditorRadiusChanged>(_onRadiusChanged);
on<ComponentEditorVerticesChanged>(_onVerticesChanged);
on<ComponentEditorPaintChanged>(_onPaintChanged);
on<ComponentEditorSelectionHighlightToggled>(_onSelectionHighlightToggled);
on<ComponentEditorSaveRequested>(_onSaveRequested);
Expand Down Expand Up @@ -128,6 +129,7 @@ class ComponentEditorBloc
takenClassNames: state.takenClassNames.keys.toSet(),
),
event.type,
polygonShape: event.polygonShape,
);
emit(
state.copyWith(
Expand Down Expand Up @@ -231,6 +233,18 @@ class ComponentEditorBloc
});
}

void _onVerticesChanged(
ComponentEditorVerticesChanged event,
Emitter<ComponentEditorState> emit,
) {
_editSelected(emit, (node) {
if (node case final VerticesComponent node) {
return node.copyWithVertices(event.vertices);
}
return node;
});
}

void _onPaintChanged(
ComponentEditorPaintChanged event,
Emitter<ComponentEditorState> emit,
Expand Down
21 changes: 19 additions & 2 deletions lib/component_editor/bloc/component_editor_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object> get props => [type];
List<Object> get props => [type, polygonShape];
}

/// The user removed the node at [path] from the component tree.
Expand Down Expand Up @@ -128,6 +133,18 @@ final class ComponentEditorRadiusChanged extends ComponentEditorEvent {
List<Object> 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<Object> get props => [vertices];
}

/// The user edited the paint of the selected [PaintedComponent].
final class ComponentEditorPaintChanged extends ComponentEditorEvent {
/// Creates a [ComponentEditorPaintChanged] event.
Expand Down
33 changes: 25 additions & 8 deletions lib/component_editor/widgets/component_tree_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,35 @@ class ComponentTreePanel extends StatelessWidget {
style: Theme.of(context).textTheme.titleSmall,
),
),
PopupMenuButton<ComponentType>(
PopupMenuButton<ComponentEditorChildAdded>(
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(
Expand Down Expand Up @@ -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,
};
}

Expand Down
11 changes: 7 additions & 4 deletions lib/component_editor/widgets/preview/component_preview_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>? highlightedPath}) {
final previous = _component;
_component = component;
Expand Down Expand Up @@ -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;
}
}
12 changes: 12 additions & 0 deletions lib/component_editor/widgets/preview/flame_mapping.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand All @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions lib/component_editor/widgets/properties_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export 'position_editor.dart';
export 'property_section.dart';
export 'radius_editor.dart';
export 'size_editor.dart';
export 'vertices_editor.dart';
61 changes: 61 additions & 0 deletions lib/component_editor/widgets/property_editors/vertices_editor.dart
Original file line number Diff line number Diff line change
@@ -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<ComponentVertices> onChanged;

Future<void> _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);
}
}
1 change: 1 addition & 0 deletions lib/project/bloc/project_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ProjectBloc extends Bloc<ProjectEvent, ProjectState> {
_project.path,
event.name,
event.type,
polygonShape: event.polygonShape,
);
emit(
state.copyWith(
Expand Down
Loading
Loading