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
8 changes: 8 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ jobs:
min_coverage: 50
runs_on: ubuntu-24.04

editor-preferences-repository:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
flutter_channel: stable
working_directory: packages/editor_preferences_repository
min_coverage: 50
runs_on: ubuntu-24.04

component-repository:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
Expand Down
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,23 @@ three pass.
(a top bar below the app bar for project-wide actions — the palette
button —, component tree on the left to add/remove/select/rename nested children,
live Flame preview in the center, with a toolbar above it whose toggle
highlights the selected node with a magenta border, properties of the selected node on the
highlights the selected node with a magenta border and whose zoom
controls (also Cmd/Ctrl + `=`/`-`/`0`) zoom the preview — the zoom is an
app-wide preference per component, in `editor_preferences_repository`,
never in the component's JSON —, properties of the selected node on the
right, explicit save with button or Cmd/Ctrl+S, unsaved-changes prompt when
leaving). **Saving also generates the component's Dart code** in the
settings' output path, with a getter for every child — see "Code
generation" in [docs/architecture.md](docs/architecture.md). Packages: `key_value_storage_client`,
`file_system_client`, `project_repository`, `component_repository`
`file_system_client`, `project_repository`, `editor_preferences_repository`, `component_repository`
(components stored in `<project>/.flame_editor/components/<name>.json`) —
see "Existing modules" in [docs/architecture.md](docs/architecture.md).
- 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
by dragging, adding from edge midpoints and deleting, with its own zoom
(not remembered) — 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
Expand Down
20 changes: 18 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ flame_component_editor/
│ │ ├── app.dart # barrel
│ │ └── view/
│ │ └── app.dart # MaterialApp, providers, theme
│ ├── widgets/ # widgets shared by several features (e.g. ZoomControls)
│ └── <feature>/ # e.g. canvas, component_tree, inspector
│ ├── <feature>.dart # barrel: exports bloc/ and view/
│ ├── bloc/
Expand Down Expand Up @@ -128,12 +129,13 @@ 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/editor_preferences_repository` | App-wide editor preferences (`shared_preferences`, through `key_value_storage_client`), not stored in the project: the preview zoom of each component, keyed by project path + component name (`component_zoom:<projectPath>:<componentName>`), clamped to `minZoom`–`maxZoom`; the `ZoomLevels` the editor canvases step through |
| 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`, `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"); the project's `Palette` (`<project>/.flame_editor/palette.json`) and its `PaletteCodeGenerator` (see "Palette") |
| 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)`: a top bar (`EditorTopBar`, below the app bar, above the three panels) for project-wide actions (the palette button), 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/component_editor/` | Component editor, `ComponentEditorPage.route(project, component)`: a top bar (`EditorTopBar`, below the app bar, above the three panels) for project-wide actions (the palette button), 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 and has the zoom controls), 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. The canvas zooms (`ZoomControls` next to the title, Cmd/Ctrl + `=`/`-`/`0`) relative to that fit, around its center (`VerticesViewTransform.zoomed`); the zoom is held by the cubit and not remembered |
| Bloc + view | `lib/palette/` | Palette dialog, `PaletteDialog.show(context, project:, component:)` (opened from the editor's top bar; returns the saved `Palette` or `null`): add, rename, change and remove named colors; colors used by a component (including the unsaved edits of the open one) can't be renamed or removed. `PaletteBloc` saves the palette and generates its code |
| Bloc + view | `lib/project_settings/` | Project settings dialog, `ProjectSettingsDialog.show(context, project)`: output path for generated Dart code (default `lib/components`, text field + Browse) |

Expand Down Expand Up @@ -234,6 +236,20 @@ the Flame component found with `flameNodeAt`, rather than a child of it:
that keeps it above overlapping siblings and keeps the Flame children
matching the component tree.

The preview can be zoomed (`ComponentEditorState.zoom`) with the zoom
out / percentage (reset to 100%) / zoom in controls on the right of the
toolbar, or with Cmd/Ctrl + `=`, `-` and `0`. Zooming in and out steps
through `ZoomLevels` (10% to 800%, in `editor_preferences_repository`).
The controls and shortcuts are the shared `ZoomControls` and
`zoomShortcuts` in `lib/widgets/`, also used by the vertices editor. The zoom is an
**editor preference, not component data**: it is remembered per component
in `EditorPreferencesRepository` (loaded on `ComponentEditorStarted`,
stored on every change) and never written to the component's JSON, so it
doesn't mark unsaved changes. `ComponentPreviewGame.zoom` sets the
camera's `viewfinder.zoom`, which zooms around the root at the center of
the screen; `SelectionHighlight` divides its stroke width by the zoom so
the border keeps the same width on screen.

Domain models avoid `dart:ui`: colors are ARGB `int`s and `PaintStyle` mirrors
`PaintingStyle`. Conversion to Flame/Flutter types happens only in
`flame_mapping.dart`. Flame's own `Component`/`RectangleComponent` clash with
Expand Down
6 changes: 6 additions & 0 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:component_repository/component_repository.dart';
import 'package:editor_preferences_repository/editor_preferences_repository.dart';
import 'package:flame_component_editor/home/home.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Expand All @@ -12,6 +13,7 @@ class App extends StatelessWidget {
const new({
required this.projectRepository,
required this.componentRepository,
required this.editorPreferencesRepository,
super.key,
});

Expand All @@ -21,12 +23,16 @@ class App extends StatelessWidget {
/// The repository used to manage the components of a project.
final ComponentRepository componentRepository;

/// The repository used to keep the editor preferences.
final EditorPreferencesRepository editorPreferencesRepository;

@override
Widget build(BuildContext context) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider.value(value: projectRepository),
RepositoryProvider.value(value: componentRepository),
RepositoryProvider.value(value: editorPreferencesRepository),
],
child: MaterialApp(
title: 'Flame Component Editor',
Expand Down
58 changes: 58 additions & 0 deletions lib/component_editor/bloc/component_editor_bloc.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:bloc/bloc.dart';
import 'package:component_repository/component_repository.dart';
import 'package:editor_preferences_repository/editor_preferences_repository.dart';
import 'package:equatable/equatable.dart';
import 'package:project_repository/project_repository.dart';

Expand All @@ -26,6 +27,7 @@ class ComponentEditorBloc
required Component component,
required this._componentRepository,
required this._projectRepository,
required this._editorPreferencesRepository,
}) : super(
ComponentEditorState(component: component, savedComponent: component),
) {
Expand All @@ -43,13 +45,17 @@ class ComponentEditorBloc
on<ComponentEditorPaintChanged>(_onPaintChanged);
on<ComponentEditorPaletteChanged>(_onPaletteChanged);
on<ComponentEditorSelectionHighlightToggled>(_onSelectionHighlightToggled);
on<ComponentEditorZoomedIn>(_onZoomedIn);
on<ComponentEditorZoomedOut>(_onZoomedOut);
on<ComponentEditorZoomReset>(_onZoomReset);
on<ComponentEditorSaveRequested>(_onSaveRequested);
}

final Project _project;

final ComponentRepository _componentRepository;
final ProjectRepository _projectRepository;
final EditorPreferencesRepository _editorPreferencesRepository;

/// How many saves were requested, so slower work started before a save
/// doesn't overwrite what the save found.
Expand All @@ -58,6 +64,10 @@ class ComponentEditorBloc
/// Whether a [ComponentEditorPaletteChanged] was handled.
bool _isPaletteChanged = false;

/// Whether the user zoomed, so the stored zoom loaded on start doesn't
/// replace the newer one.
bool _isZoomChanged = false;

/// The status after an edit: edits are allowed while saving, but must not
/// end the save, or another save could run at the same time.
ComponentEditorStatus get _editedStatus =>
Expand All @@ -75,6 +85,18 @@ class ComponentEditorBloc
bool isSaving() =>
_saves != saves || state.status == ComponentEditorStatus.saving;

try {
final zoom = await _editorPreferencesRepository.getComponentZoom(
projectPath: _project.path,
componentName: state.component.name,
);
if (!_isZoomChanged && zoom != state.zoom) {
emit(state.copyWith(zoom: zoom));
}
} on Exception {
// The preview keeps the default zoom.
}

try {
final palette = await _componentRepository.getPalette(_project.path);
// A palette changed meanwhile is newer.
Expand Down Expand Up @@ -297,6 +319,42 @@ class ComponentEditorBloc
emit(state.copyWith(isSelectionHighlighted: !state.isSelectionHighlighted));
}

Future<void> _onZoomedIn(
ComponentEditorZoomedIn event,
Emitter<ComponentEditorState> emit,
) async {
final zoom = state.nextZoom;
if (zoom != null) await _zoom(emit, zoom);
}

Future<void> _onZoomedOut(
ComponentEditorZoomedOut event,
Emitter<ComponentEditorState> emit,
) async {
final zoom = state.previousZoom;
if (zoom != null) await _zoom(emit, zoom);
}

Future<void> _onZoomReset(
ComponentEditorZoomReset event,
Emitter<ComponentEditorState> emit,
) => _zoom(emit, EditorPreferencesRepository.defaultZoom);

/// Shows the preview at [zoom] and remembers it for this component.
Future<void> _zoom(Emitter<ComponentEditorState> emit, double zoom) async {
_isZoomChanged = true;
emit(state.copyWith(zoom: zoom));
try {
await _editorPreferencesRepository.setComponentZoom(
projectPath: _project.path,
componentName: state.component.name,
zoom: zoom,
);
} on Exception {
// The zoom is only a convenience: it is just not remembered.
}
}

Future<void> _onSaveRequested(
ComponentEditorSaveRequested event,
Emitter<ComponentEditorState> emit,
Expand Down
20 changes: 20 additions & 0 deletions lib/component_editor/bloc/component_editor_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,26 @@ final class ComponentEditorSelectionHighlightToggled
const new();
}

/// The user zoomed in the preview, to the next of
/// [ZoomLevels].
final class ComponentEditorZoomedIn extends ComponentEditorEvent {
/// Creates a [ComponentEditorZoomedIn] event.
const new();
}

/// The user zoomed out the preview, to the previous of
/// [ZoomLevels].
final class ComponentEditorZoomedOut extends ComponentEditorEvent {
/// Creates a [ComponentEditorZoomedOut] event.
const new();
}

/// The user reset the zoom of the preview to 100%.
final class ComponentEditorZoomReset extends ComponentEditorEvent {
/// Creates a [ComponentEditorZoomReset] event.
const new();
}

/// The user asked to save the component.
final class ComponentEditorSaveRequested extends ComponentEditorEvent {
/// Creates a [ComponentEditorSaveRequested] event.
Expand Down
18 changes: 18 additions & 0 deletions lib/component_editor/bloc/component_editor_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ final class ComponentEditorState extends Equatable {
this.isCodeOutdated = false,
this.isSelectionHighlighted = true,
this.palette = const Palette(),
this.zoom = EditorPreferencesRepository.defaultZoom,
});

/// The current status.
Expand Down Expand Up @@ -84,6 +85,20 @@ final class ComponentEditorState extends Equatable {
/// [ComponentPaint.paletteColor]).
final Palette palette;

/// The zoom of the preview, `1` being the actual size.
///
/// It is an editor preference, remembered for each component, not part
/// of the [component].
final double zoom;

/// The zoom after zooming in (see [ZoomLevels.next]), or `null` if
/// [zoom] is the largest one.
double? get nextZoom => ZoomLevels.next(zoom);

/// The zoom after zooming out (see [ZoomLevels.previous]), or `null` if
/// [zoom] is the smallest one.
double? get previousZoom => ZoomLevels.previous(zoom);

/// Whether the component can be saved: it has changes or its code is
/// outdated, and it is not being saved already.
bool get canSave =>
Expand Down Expand Up @@ -126,6 +141,7 @@ final class ComponentEditorState extends Equatable {
bool? isCodeOutdated,
bool? isSelectionHighlighted,
Palette? palette,
double? zoom,
}) {
return ComponentEditorState(
status: status ?? this.status,
Expand All @@ -137,6 +153,7 @@ final class ComponentEditorState extends Equatable {
isSelectionHighlighted:
isSelectionHighlighted ?? this.isSelectionHighlighted,
palette: palette ?? this.palette,
zoom: zoom ?? this.zoom,
);
}

Expand All @@ -150,5 +167,6 @@ final class ComponentEditorState extends Equatable {
isCodeOutdated,
isSelectionHighlighted,
palette,
zoom,
];
}
3 changes: 3 additions & 0 deletions lib/component_editor/view/component_editor_page.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:component_repository/component_repository.dart';
import 'package:editor_preferences_repository/editor_preferences_repository.dart';
import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart';
import 'package:flame_component_editor/component_editor/view/component_editor_view.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -34,6 +35,8 @@ class ComponentEditorPage extends StatelessWidget {
component: component,
componentRepository: context.read<ComponentRepository>(),
projectRepository: context.read<ProjectRepository>(),
editorPreferencesRepository: context
.read<EditorPreferencesRepository>(),
)..add(const ComponentEditorStarted()),
child: ComponentEditorView(project: project),
);
Expand Down
Loading
Loading