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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,15 @@ three pass.
- Features: `home` (open project + recent projects), `project` (dashboard:
list/add/edit/delete components; adding asks for a name and a component
type), `project_settings` (settings dialog: generated code output path,
stored in `<project>/.flame_editor/settings.json`) and `component_editor`
(component tree on the left to add/remove/select/rename nested children,
stored in `<project>/.flame_editor/settings.json`), `palette` (dialog to
manage the project's named colors, stored in
`<project>/.flame_editor/palette.json` and generated as `GamePalette` in
`palette.generated.dart`; paint colors can be picked from it and their
generated code references `GamePalette.<name>`; colors in use can't be
renamed or removed — see "Palette" in
[docs/architecture.md](docs/architecture.md)) and `component_editor`
(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
right, explicit save with button or Cmd/Ctrl+S, unsaved-changes prompt when
Expand Down
47 changes: 45 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,12 @@ 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`, `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") |
| 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)`: 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/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/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) |

`project_repository` and `component_repository` have no Flutter imports, but
Expand Down Expand Up @@ -245,6 +246,48 @@ shortcuts. Widget tests that show the preview can't use `pumpAndSettle` (the
game loop never settles), so use `tester.pumpRouteTransition()` from
`test/helpers`.

### Palette

A project has one `Palette`: an ordered list of named colors
(`PaletteColor`), stored in `<project>/.flame_editor/palette.json` and read
and written by `ComponentRepository` (`getPalette`, `savePalette`,
`writePaletteCode`). It lives in `component_repository` because component
code references it, components can't take its file name, and "in use" is
computed from the components; a separate repository would have to depend on
this one.

- **Names** are their generated member names, so they are validated like
child names (`Palette.validateColorName`, `PaletteColorNameError`): a valid
identifier, not a keyword, unique after camelCase normalization.
- **Paints reference colors by name**: `ComponentPaint.paletteColor`. Its
`color` keeps the value the palette color had when it was picked, as a
fallback when the palette doesn't have it (`resolvedColor(palette)`).
`withColor` (picked by hand) clears the reference; `withPaletteColor`
sets it. `paletteColor` is only written to JSON when set.
- **Editing**: `ColorField` gets a palette button (a menu of the colors)
when it is given a palette and `onPaletteColorSelected`; `PaintEditor`
wires it. The editor bloc loads the palette on `ComponentEditorStarted`
and gets the saved one from the dialog with
`ComponentEditorPaletteChanged`; the preview draws
`component.withPaletteColorsResolved(palette)`. Palette changes are not
component changes (`hasChanges` is unaffected).
- **Colors in use can't be renamed or removed** (`PaletteState.usersOf`,
`ComponentTree.usedPaletteColors`); their value can change. That keeps
every reference in saved components and generated code valid.
- **Code**: `PaletteCodeGenerator` writes
`<outputPath>/palette.generated.dart` with
`abstract final class GamePalette { static const primary = Color(…); }`
(no `$` prefix: generated component classes all have one, so they never
clash). Component code uses `Paint()..color = GamePalette.primary` and
imports `palette.generated.dart` for colors the palette has; missing ones
fall back to the inline value. `writeComponentCode` also rewrites the
palette file when the component uses it, so the import always resolves
(e.g. after the output path changed), and `isComponentCodeUpToDate`
compares against code generated with the current palette. Components named
`palette` (any name whose file is `palette`) are rejected on creation, and
an older one's code is never written over the palette's
(`GeneratedFileConflictException`).

### Code generation

Saving a component in the editor also writes its Dart code to
Expand Down
23 changes: 23 additions & 0 deletions lib/component_editor/bloc/component_editor_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,23 @@ class ComponentEditorBloc
on<ComponentEditorRadiusChanged>(_onRadiusChanged);
on<ComponentEditorVerticesChanged>(_onVerticesChanged);
on<ComponentEditorPaintChanged>(_onPaintChanged);
on<ComponentEditorPaletteChanged>(_onPaletteChanged);
on<ComponentEditorSelectionHighlightToggled>(_onSelectionHighlightToggled);
on<ComponentEditorSaveRequested>(_onSaveRequested);
}

final Project _project;

final ComponentRepository _componentRepository;
final ProjectRepository _projectRepository;

/// How many saves were requested, so slower work started before a save
/// doesn't overwrite what the save found.
int _saves = 0;

/// Whether a [ComponentEditorPaletteChanged] was handled.
bool _isPaletteChanged = 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 @@ -70,6 +75,16 @@ class ComponentEditorBloc
bool isSaving() =>
_saves != saves || state.status == ComponentEditorStatus.saving;

try {
final palette = await _componentRepository.getPalette(_project.path);
// A palette changed meanwhile is newer.
if (!_isPaletteChanged && palette != state.palette) {
emit(state.copyWith(palette: palette));
}
} on Exception {
// Paints keep the last known value of their palette colors.
}

try {
final taken = await _loadTakenClassNames();
if (isSaving()) return;
Expand Down Expand Up @@ -257,6 +272,14 @@ class ComponentEditorBloc
});
}

void _onPaletteChanged(
ComponentEditorPaletteChanged event,
Emitter<ComponentEditorState> emit,
) {
_isPaletteChanged = true;
emit(state.copyWith(palette: event.palette));
}

/// Replaces the selected node with the result of [edit].
void _editSelected(
Emitter<ComponentEditorState> emit,
Expand Down
12 changes: 12 additions & 0 deletions lib/component_editor/bloc/component_editor_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ final class ComponentEditorPaintChanged extends ComponentEditorEvent {
List<Object> get props => [paint];
}

/// The palette of the project changed, e.g. in the palette dialog.
final class ComponentEditorPaletteChanged extends ComponentEditorEvent {
/// Creates a [ComponentEditorPaletteChanged] event.
const new(this.palette);

/// The new palette.
final Palette palette;

@override
List<Object> get props => [palette];
}

/// The user toggled the highlight of the selected node in the preview.
final class ComponentEditorSelectionHighlightToggled
extends ComponentEditorEvent {
Expand Down
8 changes: 8 additions & 0 deletions lib/component_editor/bloc/component_editor_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ final class ComponentEditorState extends Equatable {
this.takenClassNames = const {},
this.isCodeOutdated = false,
this.isSelectionHighlighted = true,
this.palette = const Palette(),
});

/// The current status.
Expand Down Expand Up @@ -79,6 +80,10 @@ final class ComponentEditorState extends Equatable {
/// or a radius (see [SizedComponent] and [RadiusComponent]).
final bool isSelectionHighlighted;

/// The palette of the project, whose colors paints can use (see
/// [ComponentPaint.paletteColor]).
final Palette palette;

/// 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 @@ -120,6 +125,7 @@ final class ComponentEditorState extends Equatable {
Map<String, Component>? takenClassNames,
bool? isCodeOutdated,
bool? isSelectionHighlighted,
Palette? palette,
}) {
return ComponentEditorState(
status: status ?? this.status,
Expand All @@ -130,6 +136,7 @@ final class ComponentEditorState extends Equatable {
isCodeOutdated: isCodeOutdated ?? this.isCodeOutdated,
isSelectionHighlighted:
isSelectionHighlighted ?? this.isSelectionHighlighted,
palette: palette ?? this.palette,
);
}

Expand All @@ -142,5 +149,6 @@ final class ComponentEditorState extends Equatable {
takenClassNames,
isCodeOutdated,
isSelectionHighlighted,
palette,
];
}
2 changes: 1 addition & 1 deletion lib/component_editor/view/component_editor_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ComponentEditorPage extends StatelessWidget {
componentRepository: context.read<ComponentRepository>(),
projectRepository: context.read<ProjectRepository>(),
)..add(const ComponentEditorStarted()),
child: const ComponentEditorView(),
child: ComponentEditorView(project: project),
);
}
}
47 changes: 32 additions & 15 deletions lib/component_editor/view/component_editor_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import 'package:flame_component_editor/component_editor/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:project_repository/project_repository.dart';

/// {@template component_editor_view}
/// Shows the tree of the component being edited on the left, its preview
/// in the center, below a toolbar, and the properties of the selected node
/// on the right.
/// on the right, below a top bar with the project actions (such as the
/// palette).
///
/// Changes are saved with the save button or Cmd/Ctrl+S.
/// {@endtemplate}
class ComponentEditorView extends StatelessWidget {
/// {@macro component_editor_view}
const new({super.key});
const new({required this.project, super.key});

/// The project the component belongs to.
final Project project;

/// Width of the component tree panel.
static const treePanelWidth = 240.0;
Expand Down Expand Up @@ -62,26 +67,38 @@ class ComponentEditorView extends StatelessWidget {
title: const _Title(),
actions: [_SaveButton(onPressed: save)],
),
body: const Row(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: treePanelWidth, child: ComponentTreePanel()),
VerticalDivider(width: 1),
Expanded(
child: Column(
EditorTopBar(project: project),
const Divider(height: 1),
const Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EditorToolbar(),
Divider(height: 1),
Expanded(child: ComponentPreview()),
SizedBox(
width: treePanelWidth,
child: ComponentTreePanel(),
),
VerticalDivider(width: 1),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EditorToolbar(),
Divider(height: 1),
Expanded(child: ComponentPreview()),
],
),
),
VerticalDivider(width: 1),
SizedBox(
width: propertiesPanelWidth,
child: PropertiesPanel(),
),
],
),
),
VerticalDivider(width: 1),
SizedBox(
width: propertiesPanelWidth,
child: PropertiesPanel(),
),
],
),
),
Expand Down
16 changes: 13 additions & 3 deletions lib/component_editor/widgets/component_preview.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:component_repository/component_repository.dart';
import 'package:flame/game.dart';
import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart';
import 'package:flame_component_editor/component_editor/widgets/preview/preview.dart';
Expand All @@ -8,6 +9,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
/// Renders the component being edited with Flame, highlighting the
/// selected node unless [ComponentEditorState.isSelectionHighlighted] is
/// off.
///
/// Paints that use palette colors are drawn with their current value in
/// [ComponentEditorState.palette].
/// {@endtemplate}
class ComponentPreview extends StatefulWidget {
/// {@macro component_preview}
Expand All @@ -24,11 +28,14 @@ class _ComponentPreviewState extends State<ComponentPreview> {

static ComponentPreviewGame _createGame(ComponentEditorState state) {
return ComponentPreviewGame(
component: state.component,
component: _previewed(state),
highlightedPath: _highlightedPath(state),
);
}

static Component _previewed(ComponentEditorState state) =>
state.component.withPaletteColorsResolved(state.palette);

static List<int>? _highlightedPath(ComponentEditorState state) =>
state.isSelectionHighlighted ? state.selectedPath : null;

Expand All @@ -37,12 +44,15 @@ class _ComponentPreviewState extends State<ComponentPreview> {
return BlocListener<ComponentEditorBloc, ComponentEditorState>(
listenWhen: (previous, current) =>
previous.component != current.component ||
previous.palette != current.palette ||
previous.selectedPath != current.selectedPath ||
previous.isSelectionHighlighted != current.isSelectionHighlighted,
// A single call, so a removed node and the new selection are shown
// together.
listener: (context, state) =>
_game.show(state.component, highlightedPath: _highlightedPath(state)),
listener: (context, state) => _game.show(
_previewed(state),
highlightedPath: _highlightedPath(state),
),
// The preview takes no keyboard input; without this the game widget
// would grab focus and swallow the editor shortcuts (e.g. Cmd+S).
child: GameWidget(game: _game, autofocus: false),
Expand Down
Loading
Loading