diff --git a/CLAUDE.md b/CLAUDE.md index 9ec471c..1e2167c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,8 @@ three pass. type), `project_settings` (settings dialog: generated code output path, stored in `/.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 diff --git a/docs/architecture.md b/docs/architecture.md index bc4cafc..a8f26a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `/.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 @@ -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 diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index 233fbae..f00730e 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -37,6 +37,7 @@ class ComponentEditorBloc on(_onPositionChanged); on(_onSizeChanged); on(_onPaintChanged); + on(_onSelectionHighlightToggled); on(_onSaveRequested); } @@ -213,6 +214,13 @@ class ComponentEditorBloc emit(state.copyWith(status: _editedStatus, component: component)); } + void _onSelectionHighlightToggled( + ComponentEditorSelectionHighlightToggled event, + Emitter emit, + ) { + emit(state.copyWith(isSelectionHighlighted: !state.isSelectionHighlighted)); + } + Future _onSaveRequested( ComponentEditorSaveRequested event, Emitter emit, diff --git a/lib/component_editor/bloc/component_editor_event.dart b/lib/component_editor/bloc/component_editor_event.dart index 273fc25..ace751f 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -104,6 +104,13 @@ final class ComponentEditorPaintChanged extends ComponentEditorEvent { List 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. diff --git a/lib/component_editor/bloc/component_editor_state.dart b/lib/component_editor/bloc/component_editor_state.dart index 4ef2b79..365ed3c 100644 --- a/lib/component_editor/bloc/component_editor_state.dart +++ b/lib/component_editor/bloc/component_editor_state.dart @@ -44,6 +44,7 @@ final class ComponentEditorState extends Equatable { this.selectedPath = const [], this.takenClassNames = const {}, this.isCodeOutdated = false, + this.isSelectionHighlighted = true, }); /// The current status. @@ -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 => @@ -114,6 +119,7 @@ final class ComponentEditorState extends Equatable { List? selectedPath, Map? takenClassNames, bool? isCodeOutdated, + bool? isSelectionHighlighted, }) { return ComponentEditorState( status: status ?? this.status, @@ -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, ); } @@ -133,5 +141,6 @@ final class ComponentEditorState extends Equatable { selectedPath, takenClassNames, isCodeOutdated, + isSelectionHighlighted, ]; } diff --git a/lib/component_editor/view/component_editor_view.dart b/lib/component_editor/view/component_editor_view.dart index ec206be..60762b5 100644 --- a/lib/component_editor/view/component_editor_view.dart +++ b/lib/component_editor/view/component_editor_view.dart @@ -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} @@ -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, diff --git a/lib/component_editor/widgets/component_preview.dart b/lib/component_editor/widgets/component_preview.dart index 28d70f2..13fd226 100644 --- a/lib/component_editor/widgets/component_preview.dart +++ b/lib/component_editor/widgets/component_preview.dart @@ -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} @@ -16,16 +18,31 @@ class ComponentPreview extends StatefulWidget { } class _ComponentPreviewState extends State { - late final _game = ComponentPreviewGame( - component: context.read().state.component, + late final ComponentPreviewGame _game = _createGame( + context.read().state, ); + static ComponentPreviewGame _createGame(ComponentEditorState state) { + return ComponentPreviewGame( + component: state.component, + highlightedPath: _highlightedPath(state), + ); + } + + static List? _highlightedPath(ComponentEditorState state) => + state.isSelectionHighlighted ? state.selectedPath : null; + @override Widget build(BuildContext context) { return BlocListener( 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), diff --git a/lib/component_editor/widgets/editor_toolbar.dart b/lib/component_editor/widgets/editor_toolbar.dart new file mode 100644 index 0000000..bbbed62 --- /dev/null +++ b/lib/component_editor/widgets/editor_toolbar.dart @@ -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( + (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().add( + const ComponentEditorSelectionHighlightToggled(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/component_editor/widgets/preview/component_preview_game.dart b/lib/component_editor/widgets/preview/component_preview_game.dart index 411bf58..96e6612 100644 --- a/lib/component_editor/widgets/preview/component_preview_game.dart +++ b/lib/component_editor/widgets/preview/component_preview_game.dart @@ -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? _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? highlightedPath}) { final previous = _component; _component = component; + _highlightedPath = highlightedPath; if (!isLoaded) return; if (haveSameShape(previous, component)) { applyTree(_preview, component); + _updateHighlight(); } else { _rebuild(); } @@ -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; } } diff --git a/lib/component_editor/widgets/preview/flame_mapping.dart b/lib/component_editor/widgets/preview/flame_mapping.dart index 93781aa..a700ce6 100644 --- a/lib/component_editor/widgets/preview/flame_mapping.dart +++ b/lib/component_editor/widgets/preview/flame_mapping.dart @@ -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 path, +) { + var node = root; + for (final index in path) { + node = node.children.whereType().elementAt(index); + } + return node; +} + /// Applies every property [component] has to [target]. /// /// New shared properties should be wired here so that every component type diff --git a/lib/component_editor/widgets/preview/preview.dart b/lib/component_editor/widgets/preview/preview.dart index a556fcb..5f46915 100644 --- a/lib/component_editor/widgets/preview/preview.dart +++ b/lib/component_editor/widgets/preview/preview.dart @@ -1,2 +1,3 @@ export 'component_preview_game.dart'; export 'flame_mapping.dart'; +export 'selection_highlight.dart'; diff --git a/lib/component_editor/widgets/preview/selection_highlight.dart b/lib/component_editor/widgets/preview/selection_highlight.dart new file mode 100644 index 0000000..059aaed --- /dev/null +++ b/lib/component_editor/widgets/preview/selection_highlight.dart @@ -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); + } +} diff --git a/lib/component_editor/widgets/widgets.dart b/lib/component_editor/widgets/widgets.dart index a161e59..89f7357 100644 --- a/lib/component_editor/widgets/widgets.dart +++ b/lib/component_editor/widgets/widgets.dart @@ -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'; diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index 6bf26c9..d91a82e 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -79,6 +79,26 @@ void main() { ], ); + test('highlights the selection by default', () { + expect(buildBloc().state.isSelectionHighlighted, isTrue); + }); + + blocTest( + 'ComponentEditorSelectionHighlightToggled toggles the highlight', + build: buildBloc, + act: (bloc) => bloc + ..add(const ComponentEditorSelectionHighlightToggled()) + ..add(const ComponentEditorSelectionHighlightToggled()), + expect: () => const [ + ComponentEditorState( + component: component, + savedComponent: component, + isSelectionHighlighted: false, + ), + ComponentEditorState(component: component, savedComponent: component), + ], + ); + test('rootClassOwner is the other component generating the root ' 'class', () { const aB = RectangleComponent(name: 'a_b'); diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index c0a7e7d..110d858 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -94,6 +94,91 @@ void main() { expect(find.byType(PaintEditor), findsOneWidget); }); + group('highlight toggle', () { + IconButton toggle(WidgetTester tester) => tester.widget( + find.ancestor( + of: find.byIcon(Icons.highlight_alt), + matching: find.byType(IconButton), + ), + ); + + testWidgets('is selected while highlighting', (tester) async { + await tester.pumpApp(buildSubject()); + + expect(toggle(tester).isSelected, isTrue); + }); + + testWidgets('is not selected while not highlighting', (tester) async { + when(() => bloc.state).thenReturn( + const ComponentEditorState( + component: component, + savedComponent: component, + isSelectionHighlighted: false, + ), + ); + await tester.pumpApp(buildSubject()); + + expect(toggle(tester).isSelected, isFalse); + }); + + testWidgets('is filled only when selected', (tester) async { + await tester.pumpApp(buildSubject()); + final colorScheme = Theme.of(tester.element(find.byType(EditorToolbar))) + .colorScheme; + final background = toggle(tester).style!.backgroundColor!; + + expect( + background.resolve({WidgetState.selected}), + equals(colorScheme.primaryContainer), + ); + expect(background.resolve({}), isNull); + }); + }); + + testWidgets('toolbar sits above the preview only', (tester) async { + await tester.pumpApp(buildSubject()); + + final toolbar = tester.getRect(find.byType(EditorToolbar)); + final preview = tester.getRect(find.byType(ComponentPreview)); + expect(toolbar.left, equals(preview.left)); + expect(toolbar.width, equals(preview.width)); + expect(toolbar.bottom, lessThanOrEqualTo(preview.top)); + }); + + testWidgets('dispatches highlight toggles', (tester) async { + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byTooltip('Highlight selection')); + + verify(() => bloc.add(const ComponentEditorSelectionHighlightToggled())) + .called(1); + }); + + testWidgets('preview highlights the selection while enabled', ( + tester, + ) async { + const state = ComponentEditorState( + component: component, + savedComponent: component, + ); + final states = StreamController(); + whenListen(bloc, states.stream, initialState: state); + await tester.pumpApp(buildSubject()); + await tester.pump(); + final game = tester + .widget>( + find.byType(GameWidget), + ) + .game!; + expect(game.highlight.target, same(game.preview)); + + states.add(state.copyWith(isSelectionHighlighted: false)); + await tester.pump(); + + expect(game.highlight.target, isNull); + await states.close(); + }); + testWidgets('dispatches size changes', (tester) async { 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 7c2a4a3..cf22d7b 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -106,6 +106,72 @@ void main() { ); }); + group('highlight', () { + const child = RectangleComponent(name: 'rectangle1'); + final tree = component.copyWithChildren(const [child]); + + testWithGame( + 'targets the root', + () => ComponentPreviewGame(component: component, highlightedPath: []), + (game) async { + await game.ready(); + + expect(game.world.children, contains(game.highlight)); + expect(game.highlight.target, same(game.preview)); + }, + ); + + testWithGame( + 'targets nothing without a path', + () => ComponentPreviewGame(component: component), + (game) async { + await game.ready(); + + expect(game.highlight.target, isNull); + }, + ); + + testWithGame( + 'follows the highlighted path', + () => ComponentPreviewGame(component: tree, highlightedPath: []), + (game) async { + await game.ready(); + + game.show(tree, highlightedPath: [0]); + expect(game.highlight.target, same(flameNodeAt(game.preview, [0]))); + + game.show(tree); + expect(game.highlight.target, isNull); + }, + ); + + testWithGame( + 'targets the rebuilt component', + () => ComponentPreviewGame(component: component, highlightedPath: []), + (game) async { + await game.ready(); + + game.show(tree, highlightedPath: [0]); + await game.ready(); + + final rendered = game.preview.children + .whereType() + .single; + expect(game.highlight.target, same(rendered)); + }, + ); + + testWithGame( + 'ignores paths outside of the tree', + () => ComponentPreviewGame(component: component, highlightedPath: [3]), + (game) async { + await game.ready(); + + expect(game.highlight.target, isNull); + }, + ); + }); + test('haveSameShape compares the types and the tree', () { const child = RectangleComponent(name: 'rectangle1');