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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ three pass.
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,
live Flame preview in the center, properties of the selected node on the
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
leaving). **Saving also generates the component's Dart code** in the
settings' output path, with a getter for every child — see "Code
Expand Down
10 changes: 9 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ dependencies:
| 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") |
| 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), 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)`: 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/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 @@ -191,6 +191,14 @@ and are positioned relative to their parent. `ComponentPreviewGame.show`
updates the Flame components in place while the tree shape is unchanged
(`haveSameShape`) and rebuilds them otherwise.

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`) 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:
that keeps it above overlapping siblings and keeps the Flame children
matching the component tree.

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
8 changes: 8 additions & 0 deletions lib/component_editor/bloc/component_editor_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ComponentEditorBloc
on<ComponentEditorPositionChanged>(_onPositionChanged);
on<ComponentEditorSizeChanged>(_onSizeChanged);
on<ComponentEditorPaintChanged>(_onPaintChanged);
on<ComponentEditorSelectionHighlightToggled>(_onSelectionHighlightToggled);
on<ComponentEditorSaveRequested>(_onSaveRequested);
}

Expand Down Expand Up @@ -213,6 +214,13 @@ class ComponentEditorBloc
emit(state.copyWith(status: _editedStatus, component: component));
}

void _onSelectionHighlightToggled(
ComponentEditorSelectionHighlightToggled event,
Emitter<ComponentEditorState> emit,
) {
emit(state.copyWith(isSelectionHighlighted: !state.isSelectionHighlighted));
}

Future<void> _onSaveRequested(
ComponentEditorSaveRequested event,
Emitter<ComponentEditorState> emit,
Expand Down
7 changes: 7 additions & 0 deletions lib/component_editor/bloc/component_editor_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ final class ComponentEditorPaintChanged extends ComponentEditorEvent {
List<Object> get props => [paint];
}

/// The user toggled the highlight of the selected node in the preview.
final class ComponentEditorSelectionHighlightToggled
extends ComponentEditorEvent {
/// Creates a [ComponentEditorSelectionHighlightToggled] event.
const new();
}

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

/// The current status.
Expand Down Expand Up @@ -74,6 +75,10 @@ final class ComponentEditorState extends Equatable {
/// code once whatever was in the way is fixed.
final bool isCodeOutdated;

/// Whether the preview highlights the selected node, when it has a size
/// (see [SizedComponent]).
final bool isSelectionHighlighted;

/// 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 @@ -114,6 +119,7 @@ final class ComponentEditorState extends Equatable {
List<int>? selectedPath,
Map<String, Component>? takenClassNames,
bool? isCodeOutdated,
bool? isSelectionHighlighted,
}) {
return ComponentEditorState(
status: status ?? this.status,
Expand All @@ -122,6 +128,8 @@ final class ComponentEditorState extends Equatable {
selectedPath: selectedPath ?? this.selectedPath,
takenClassNames: takenClassNames ?? this.takenClassNames,
isCodeOutdated: isCodeOutdated ?? this.isCodeOutdated,
isSelectionHighlighted:
isSelectionHighlighted ?? this.isSelectionHighlighted,
);
}

Expand All @@ -133,5 +141,6 @@ final class ComponentEditorState extends Equatable {
selectedPath,
takenClassNames,
isCodeOutdated,
isSelectionHighlighted,
];
}
14 changes: 12 additions & 2 deletions lib/component_editor/view/component_editor_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';

/// {@template component_editor_view}
/// Shows the tree of the component being edited on the left, its preview
/// in the center and the properties of the selected node on the right.
/// in the center, below a toolbar, and the properties of the selected node
/// on the right.
///
/// Changes are saved with the save button or Cmd/Ctrl+S.
/// {@endtemplate}
Expand Down Expand Up @@ -66,7 +67,16 @@ class ComponentEditorView extends StatelessWidget {
children: [
SizedBox(width: treePanelWidth, child: ComponentTreePanel()),
VerticalDivider(width: 1),
Expanded(child: ComponentPreview()),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EditorToolbar(),
Divider(height: 1),
Expanded(child: ComponentPreview()),
],
),
),
VerticalDivider(width: 1),
SizedBox(
width: propertiesPanelWidth,
Expand Down
27 changes: 22 additions & 5 deletions lib/component_editor/widgets/component_preview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// {@template component_preview}
/// Renders the component being edited with Flame.
/// Renders the component being edited with Flame, highlighting the
/// selected node unless [ComponentEditorState.isSelectionHighlighted] is
/// off.
/// {@endtemplate}
class ComponentPreview extends StatefulWidget {
/// {@macro component_preview}
Expand All @@ -16,16 +18,31 @@ class ComponentPreview extends StatefulWidget {
}

class _ComponentPreviewState extends State<ComponentPreview> {
late final _game = ComponentPreviewGame(
component: context.read<ComponentEditorBloc>().state.component,
late final ComponentPreviewGame _game = _createGame(
context.read<ComponentEditorBloc>().state,
);

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

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

@override
Widget build(BuildContext context) {
return BlocListener<ComponentEditorBloc, ComponentEditorState>(
listenWhen: (previous, current) =>
previous.component != current.component,
listener: (context, state) => _game.show(state.component),
previous.component != current.component ||
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)),
// 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
56 changes: 56 additions & 0 deletions lib/component_editor/widgets/editor_toolbar.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// {@template editor_toolbar}
/// The bar above the preview, with the preview options.
/// {@endtemplate}
class EditorToolbar extends StatelessWidget {
/// {@macro editor_toolbar}
const new({super.key});

/// Height of the toolbar.
static const height = 40.0;

@override
Widget build(BuildContext context) {
final isSelectionHighlighted = context.select<ComponentEditorBloc, bool>(
(bloc) => bloc.state.isSelectionHighlighted,
);
final colorScheme = Theme.of(context).colorScheme;
return ColoredBox(
color: colorScheme.surfaceContainer,
child: SizedBox(
height: height,
child: Row(
children: [
const SizedBox(width: 8),
IconButton(
tooltip: 'Highlight selection',
iconSize: 20,
isSelected: isSelectionHighlighted,
icon: const Icon(Icons.highlight_alt),
// Selected toggles get a filled background, so it's clear at
// a glance whether the highlight is on.
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? colorScheme.primaryContainer
: null,
),
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
onPressed: () => context.read<ComponentEditorBloc>().add(
const ComponentEditorSelectionHighlightToggled(),
),
),
],
),
),
);
}
}
34 changes: 29 additions & 5 deletions lib/component_editor/widgets/preview/component_preview_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,55 @@ import 'package:component_repository/component_repository.dart';
import 'package:flame/components.dart' as flame;
import 'package:flame/game.dart';
import 'package:flame_component_editor/component_editor/widgets/preview/flame_mapping.dart';
import 'package:flame_component_editor/component_editor/widgets/preview/selection_highlight.dart';
import 'package:flutter/painting.dart';

/// {@template component_preview_game}
/// A Flame game that renders a [Component] and its children, centered on
/// screen.
/// screen, and highlights one of them.
///
/// The game only renders: the component being edited is owned by the
/// `ComponentEditorBloc` and pushed here with [show].
/// {@endtemplate}
class ComponentPreviewGame extends FlameGame {
/// {@macro component_preview_game}
new({required this._component});
new({required this._component, this._highlightedPath});

Component _component;
List<int>? _highlightedPath;
late flame.PositionComponent _preview;

/// The Flame component currently rendering the previewed root
/// [Component]. Its children render the [Component] children.
flame.PositionComponent get preview => _preview;

/// Draws the border around the highlighted node.
final highlight = SelectionHighlight();

@override
Color backgroundColor() => const Color(0xFF1E1E1E);

@override
void onLoad() => _rebuild();
void onLoad() {
world.add(highlight);
_rebuild();
}

/// Renders [component] instead of the current one.
/// Renders [component] instead of the current one, highlighting the node
/// at [highlightedPath] (see [ComponentTree]), if any.
///
/// Only nodes with a size ([SizedComponent]) are highlighted.
///
/// The existing Flame components are updated in place, unless components
/// were added, removed or changed type (see [haveSameShape]).
void show(Component component) {
void show(Component component, {List<int>? highlightedPath}) {
final previous = _component;
_component = component;
_highlightedPath = highlightedPath;
if (!isLoaded) return;
if (haveSameShape(previous, component)) {
applyTree(_preview, component);
_updateHighlight();
} else {
_rebuild();
}
Expand All @@ -47,5 +60,16 @@ class ComponentPreviewGame extends FlameGame {
if (isLoaded) _preview.removeFromParent();
_preview = buildFlameComponent(_component);
world.add(_preview);
_updateHighlight();
}

void _updateHighlight() {
final path = _highlightedPath;
highlight.target =
path != null &&
_component.hasNodeAt(path) &&
_component.nodeAt(path) is SizedComponent
? flameNodeAt(_preview, path)
: null;
}
}
13 changes: 13 additions & 0 deletions lib/component_editor/widgets/preview/flame_mapping.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ flame.PositionComponent _applyNode(
return target;
}

/// The Flame component at [path] in the tree built by
/// [buildFlameComponent] from [root] (see `ComponentTree` for paths).
flame.PositionComponent flameNodeAt(
flame.PositionComponent root,
List<int> path,
) {
var node = root;
for (final index in path) {
node = node.children.whereType<flame.PositionComponent>().elementAt(index);
}
return node;
}

/// Applies every property [component] has to [target].
///
/// New shared properties should be wired here so that every component type
Expand Down
1 change: 1 addition & 0 deletions lib/component_editor/widgets/preview/preview.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export 'component_preview_game.dart';
export 'flame_mapping.dart';
export 'selection_highlight.dart';
37 changes: 37 additions & 0 deletions lib/component_editor/widgets/preview/selection_highlight.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'package:flame/components.dart' as flame;
import 'package:flutter/painting.dart';

/// {@template selection_highlight}
/// Draws a thick magenta border around [target], the Flame component of
/// the node selected in the editor.
///
/// It is added to the world, above everything else, instead of to
/// [target]: that keeps it on top of overlapping siblings, and the
/// children of the previewed components match the component tree.
/// {@endtemplate}
class SelectionHighlight extends flame.Component {
/// {@macro selection_highlight}
new() : super(priority: 1 << 30);

/// The color of the border.
static const color = Color(0xFFFF00FF);

/// The width of the border.
static const strokeWidth = 4.0;

static final _paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;

/// The component to highlight, if any.
flame.PositionComponent? target;

@override
void render(Canvas canvas) {
final target = this.target;
if (target == null || !target.isMounted) return;
// Drawn outside of the component, so it doesn't hide its own edges.
canvas.drawRect(target.toAbsoluteRect().inflate(strokeWidth / 2), _paint);
}
}
1 change: 1 addition & 0 deletions lib/component_editor/widgets/widgets.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export 'component_preview.dart';
export 'component_tree_panel.dart';
export 'editor_toolbar.dart';
export 'preview/preview.dart';
export 'properties_panel.dart';
export 'property_editors/property_editors.dart';
Expand Down
Loading
Loading