From f4a5a07461a457827241c62b5d129f34c76fe8c6 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Tue, 20 Oct 2026 12:07:07 -0300 Subject: [PATCH] feat: Zoom --- .github/workflows/main.yaml | 8 + CLAUDE.md | 10 +- docs/architecture.md | 20 +- lib/app/view/app.dart | 6 + .../bloc/component_editor_bloc.dart | 58 ++++++ .../bloc/component_editor_event.dart | 20 ++ .../bloc/component_editor_state.dart | 18 ++ .../view/component_editor_page.dart | 3 + .../view/component_editor_view.dart | 18 +- .../widgets/component_preview.dart | 39 ++-- .../widgets/editor_toolbar.dart | 30 ++- .../preview/component_preview_game.dart | 19 +- .../widgets/preview/selection_highlight.dart | 18 +- lib/main.dart | 8 +- .../bloc/vertices_editor_cubit.dart | 20 ++ .../bloc/vertices_editor_state.dart | 15 +- .../view/vertices_editor_view.dart | 166 ++++++++------- .../widgets/vertices_canvas.dart | 12 +- .../widgets/vertices_view_transform.dart | 20 ++ lib/widgets/widgets.dart | 1 + lib/widgets/zoom_controls.dart | 104 ++++++++++ .../editor_preferences_repository/.gitignore | 4 + .../analysis_options.yaml | 5 + .../lib/editor_preferences_repository.dart | 6 + .../src/editor_preferences_repository.dart | 58 ++++++ .../lib/src/models/models.dart | 1 + .../lib/src/models/zoom_levels.dart | 44 ++++ .../pubspec.yaml | 19 ++ .../editor_preferences_repository_test.dart | 107 ++++++++++ .../test/src/models/zoom_levels_test.dart | 25 +++ pubspec.lock | 7 + pubspec.yaml | 2 + test/app/view/app_test.dart | 5 + .../bloc/component_editor_bloc_test.dart | 194 ++++++++++++++++++ .../view/component_editor_page_test.dart | 91 ++++++++ .../preview/component_preview_game_test.dart | 67 ++++++ test/helpers/pump_app.dart | 26 +++ .../bloc/vertices_editor_cubit_test.dart | 59 ++++++ .../view/vertices_editor_dialog_test.dart | 68 ++++++ .../widgets/vertices_view_transform_test.dart | 12 ++ test/widgets/zoom_controls_test.dart | 98 +++++++++ 41 files changed, 1404 insertions(+), 107 deletions(-) create mode 100644 lib/widgets/widgets.dart create mode 100644 lib/widgets/zoom_controls.dart create mode 100644 packages/editor_preferences_repository/.gitignore create mode 100644 packages/editor_preferences_repository/analysis_options.yaml create mode 100644 packages/editor_preferences_repository/lib/editor_preferences_repository.dart create mode 100644 packages/editor_preferences_repository/lib/src/editor_preferences_repository.dart create mode 100644 packages/editor_preferences_repository/lib/src/models/models.dart create mode 100644 packages/editor_preferences_repository/lib/src/models/zoom_levels.dart create mode 100644 packages/editor_preferences_repository/pubspec.yaml create mode 100644 packages/editor_preferences_repository/test/src/editor_preferences_repository_test.dart create mode 100644 packages/editor_preferences_repository/test/src/models/zoom_levels_test.dart create mode 100644 test/widgets/zoom_controls_test.dart diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 0a3cdc9..9d72700 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 9c0af1d..a2158da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `/.flame_editor/components/.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 diff --git a/docs/architecture.md b/docs/architecture.md index 2db78f6..df505ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) │ └── / # e.g. canvas, component_tree, inspector │ ├── .dart # barrel: exports bloc/ and view/ │ ├── bloc/ @@ -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::`), 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 (`/.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 `/.flame_editor/components/`); `ComponentCodeGenerator` and writing/deleting the generated Dart code (see "Code generation"); the project's `Palette` (`/.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) | @@ -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 diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c3dfbe7..205eb93 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -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'; @@ -12,6 +13,7 @@ class App extends StatelessWidget { const new({ required this.projectRepository, required this.componentRepository, + required this.editorPreferencesRepository, super.key, }); @@ -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', diff --git a/lib/component_editor/bloc/component_editor_bloc.dart b/lib/component_editor/bloc/component_editor_bloc.dart index 5997a2e..e63b8b3 100644 --- a/lib/component_editor/bloc/component_editor_bloc.dart +++ b/lib/component_editor/bloc/component_editor_bloc.dart @@ -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'; @@ -26,6 +27,7 @@ class ComponentEditorBloc required Component component, required this._componentRepository, required this._projectRepository, + required this._editorPreferencesRepository, }) : super( ComponentEditorState(component: component, savedComponent: component), ) { @@ -43,6 +45,9 @@ class ComponentEditorBloc on(_onPaintChanged); on(_onPaletteChanged); on(_onSelectionHighlightToggled); + on(_onZoomedIn); + on(_onZoomedOut); + on(_onZoomReset); on(_onSaveRequested); } @@ -50,6 +55,7 @@ class ComponentEditorBloc 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. @@ -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 => @@ -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. @@ -297,6 +319,42 @@ class ComponentEditorBloc emit(state.copyWith(isSelectionHighlighted: !state.isSelectionHighlighted)); } + Future _onZoomedIn( + ComponentEditorZoomedIn event, + Emitter emit, + ) async { + final zoom = state.nextZoom; + if (zoom != null) await _zoom(emit, zoom); + } + + Future _onZoomedOut( + ComponentEditorZoomedOut event, + Emitter emit, + ) async { + final zoom = state.previousZoom; + if (zoom != null) await _zoom(emit, zoom); + } + + Future _onZoomReset( + ComponentEditorZoomReset event, + Emitter emit, + ) => _zoom(emit, EditorPreferencesRepository.defaultZoom); + + /// Shows the preview at [zoom] and remembers it for this component. + Future _zoom(Emitter 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 _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 d066f66..8ece258 100644 --- a/lib/component_editor/bloc/component_editor_event.dart +++ b/lib/component_editor/bloc/component_editor_event.dart @@ -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. diff --git a/lib/component_editor/bloc/component_editor_state.dart b/lib/component_editor/bloc/component_editor_state.dart index c004886..7da7670 100644 --- a/lib/component_editor/bloc/component_editor_state.dart +++ b/lib/component_editor/bloc/component_editor_state.dart @@ -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. @@ -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 => @@ -126,6 +141,7 @@ final class ComponentEditorState extends Equatable { bool? isCodeOutdated, bool? isSelectionHighlighted, Palette? palette, + double? zoom, }) { return ComponentEditorState( status: status ?? this.status, @@ -137,6 +153,7 @@ final class ComponentEditorState extends Equatable { isSelectionHighlighted: isSelectionHighlighted ?? this.isSelectionHighlighted, palette: palette ?? this.palette, + zoom: zoom ?? this.zoom, ); } @@ -150,5 +167,6 @@ final class ComponentEditorState extends Equatable { isCodeOutdated, isSelectionHighlighted, palette, + zoom, ]; } diff --git a/lib/component_editor/view/component_editor_page.dart b/lib/component_editor/view/component_editor_page.dart index 899c2c6..b38481d 100644 --- a/lib/component_editor/view/component_editor_page.dart +++ b/lib/component_editor/view/component_editor_page.dart @@ -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'; @@ -34,6 +35,8 @@ class ComponentEditorPage extends StatelessWidget { component: component, componentRepository: context.read(), projectRepository: context.read(), + editorPreferencesRepository: context + .read(), )..add(const ComponentEditorStarted()), child: ComponentEditorView(project: project), ); diff --git a/lib/component_editor/view/component_editor_view.dart b/lib/component_editor/view/component_editor_view.dart index 80bad6f..80f47f2 100644 --- a/lib/component_editor/view/component_editor_view.dart +++ b/lib/component_editor/view/component_editor_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:component_repository/component_repository.dart'; import 'package:flame_component_editor/component_editor/bloc/component_editor_bloc.dart'; import 'package:flame_component_editor/component_editor/widgets/widgets.dart'; +import 'package:flame_component_editor/widgets/widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -14,7 +15,9 @@ import 'package:project_repository/project_repository.dart'; /// 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. +/// Changes are saved with the save button or Cmd/Ctrl+S. The preview zooms +/// in with Cmd/Ctrl+=, out with Cmd/Ctrl+- and back to 100% with +/// Cmd/Ctrl+0. /// {@endtemplate} class ComponentEditorView extends StatelessWidget { /// {@macro component_editor_view} @@ -31,9 +34,11 @@ class ComponentEditorView extends StatelessWidget { @override Widget build(BuildContext context) { - void save() => context.read().add( - const ComponentEditorSaveRequested(), - ); + final bloc = context.read(); + void save() => bloc.add(const ComponentEditorSaveRequested()); + void zoomIn() => bloc.add(const ComponentEditorZoomedIn()); + void zoomOut() => bloc.add(const ComponentEditorZoomedOut()); + void resetZoom() => bloc.add(const ComponentEditorZoomReset()); return BlocListener( listenWhen: (previous, current) => previous.status != current.status, @@ -58,6 +63,11 @@ class ComponentEditorView extends StatelessWidget { bindings: { const SingleActivator(LogicalKeyboardKey.keyS, meta: true): save, const SingleActivator(LogicalKeyboardKey.keyS, control: true): save, + ...zoomShortcuts( + onZoomIn: zoomIn, + onZoomOut: zoomOut, + onReset: resetZoom, + ), }, child: _UnsavedChangesGuard( child: Focus( diff --git a/lib/component_editor/widgets/component_preview.dart b/lib/component_editor/widgets/component_preview.dart index 673186c..040b737 100644 --- a/lib/component_editor/widgets/component_preview.dart +++ b/lib/component_editor/widgets/component_preview.dart @@ -6,9 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// {@template component_preview} -/// Renders the component being edited with Flame, highlighting the -/// selected node unless [ComponentEditorState.isSelectionHighlighted] is -/// off. +/// Renders the component being edited with Flame, at +/// [ComponentEditorState.zoom], highlighting the selected node unless +/// [ComponentEditorState.isSelectionHighlighted] is off. /// /// Paints that use palette colors are drawn with their current value in /// [ComponentEditorState.palette]. @@ -30,6 +30,7 @@ class _ComponentPreviewState extends State { return ComponentPreviewGame( component: _previewed(state), highlightedPath: _highlightedPath(state), + zoom: state.zoom, ); } @@ -41,18 +42,26 @@ class _ComponentPreviewState extends State { @override Widget build(BuildContext context) { - return BlocListener( - 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( - _previewed(state), - highlightedPath: _highlightedPath(state), - ), + return MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => previous.zoom != current.zoom, + listener: (context, state) => _game.zoom = state.zoom, + ), + BlocListener( + 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( + _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), diff --git a/lib/component_editor/widgets/editor_toolbar.dart b/lib/component_editor/widgets/editor_toolbar.dart index bbbed62..3792090 100644 --- a/lib/component_editor/widgets/editor_toolbar.dart +++ b/lib/component_editor/widgets/editor_toolbar.dart @@ -1,9 +1,12 @@ +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/widgets/widgets.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. +/// The bar above the preview, with the preview options: the selection +/// highlight and the zoom. /// {@endtemplate} class EditorToolbar extends StatelessWidget { /// {@macro editor_toolbar} @@ -48,9 +51,34 @@ class EditorToolbar extends StatelessWidget { const ComponentEditorSelectionHighlightToggled(), ), ), + const Spacer(), + const _EditorZoomControls(), + const SizedBox(width: 8), ], ), ), ); } } + +class _EditorZoomControls extends StatelessWidget { + const new(); + + @override + Widget build(BuildContext context) { + final zoom = context.select( + (bloc) => bloc.state.zoom, + ); + final bloc = context.read(); + return ZoomControls( + zoom: zoom, + onZoomOut: ZoomLevels.previous(zoom) == null + ? null + : () => bloc.add(const ComponentEditorZoomedOut()), + onZoomIn: ZoomLevels.next(zoom) == null + ? null + : () => bloc.add(const ComponentEditorZoomedIn()), + onReset: () => bloc.add(const ComponentEditorZoomReset()), + ); + } +} diff --git a/lib/component_editor/widgets/preview/component_preview_game.dart b/lib/component_editor/widgets/preview/component_preview_game.dart index ec3e37c..e78533c 100644 --- a/lib/component_editor/widgets/preview/component_preview_game.dart +++ b/lib/component_editor/widgets/preview/component_preview_game.dart @@ -7,17 +7,18 @@ import 'package:flutter/painting.dart'; /// {@template component_preview_game} /// A Flame game that renders a [Component] and its children, centered on -/// screen, and highlights one of them. +/// screen and scaled by [zoom], 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, this._highlightedPath}); + new({required this._component, this._highlightedPath, this._zoom = 1}); Component _component; List? _highlightedPath; + double _zoom; late flame.PositionComponent _preview; /// The Flame component currently rendering the previewed root @@ -27,12 +28,21 @@ class ComponentPreviewGame extends FlameGame { /// Draws the border around the highlighted node. final highlight = SelectionHighlight(); + /// How much the preview is scaled, `1` being the actual size. It zooms + /// around the center of the screen, where the root is. + double get zoom => _zoom; + set zoom(double value) { + _zoom = value; + if (isLoaded) _applyZoom(); + } + @override Color backgroundColor() => const Color(0xFF1E1E1E); @override void onLoad() { world.add(highlight); + _applyZoom(); _rebuild(); } @@ -58,6 +68,11 @@ class ComponentPreviewGame extends FlameGame { } } + void _applyZoom() { + camera.viewfinder.zoom = _zoom; + highlight.zoom = _zoom; + } + void _rebuild() { if (isLoaded) _preview.removeFromParent(); _preview = buildFlameComponent(_component); diff --git a/lib/component_editor/widgets/preview/selection_highlight.dart b/lib/component_editor/widgets/preview/selection_highlight.dart index 0813179..e4f8ac6 100644 --- a/lib/component_editor/widgets/preview/selection_highlight.dart +++ b/lib/component_editor/widgets/preview/selection_highlight.dart @@ -16,10 +16,10 @@ class SelectionHighlight extends flame.Component { /// The color of the border. static const color = Color(0xFFFF00FF); - /// The width of the border. + /// The width of the border on screen, whatever the [zoom]. static const strokeWidth = 4.0; - static final _paint = Paint() + final _paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth; @@ -27,6 +27,18 @@ class SelectionHighlight extends flame.Component { /// The component to highlight, if any. flame.PositionComponent? target; + /// The zoom of the camera, which scales the border too: it is drawn + /// thinner when zoomed in, so it keeps the same width on screen. + double get zoom => _zoom; + double _zoom = 1; + set zoom(double value) { + _zoom = value; + _paint.strokeWidth = worldStrokeWidth; + } + + /// The width of the border in world coordinates. + double get worldStrokeWidth => strokeWidth / _zoom; + @override void render(Canvas canvas) { final target = this.target; @@ -41,7 +53,7 @@ class SelectionHighlight extends flame.Component { ..rotate(target.absoluteAngle) // Drawn outside of the component, so it doesn't hide its own edges. ..drawRect( - Rect.fromLTWH(0, 0, size.x, size.y).inflate(strokeWidth / 2), + Rect.fromLTWH(0, 0, size.x, size.y).inflate(worldStrokeWidth / 2), _paint, ) ..restore(); diff --git a/lib/main.dart b/lib/main.dart index f243523..7d45276 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:component_repository/component_repository.dart'; +import 'package:editor_preferences_repository/editor_preferences_repository.dart'; import 'package:file_system_client/file_system_client.dart'; import 'package:flame_component_editor/app/app.dart'; import 'package:flutter/widgets.dart'; @@ -9,17 +10,22 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); final fileSystem = FileSystemClient(); + final storage = KeyValueStorageClient(); final projectRepository = ProjectRepository( - storage: KeyValueStorageClient(), + storage: storage, fileSystem: fileSystem, ); final componentRepository = ComponentRepository(fileSystem: fileSystem); + final editorPreferencesRepository = EditorPreferencesRepository( + storage: storage, + ); runApp( App( projectRepository: projectRepository, componentRepository: componentRepository, + editorPreferencesRepository: editorPreferencesRepository, ), ); } diff --git a/lib/vertices_editor/bloc/vertices_editor_cubit.dart b/lib/vertices_editor/bloc/vertices_editor_cubit.dart index c443399..fbe64a1 100644 --- a/lib/vertices_editor/bloc/vertices_editor_cubit.dart +++ b/lib/vertices_editor/bloc/vertices_editor_cubit.dart @@ -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'; part 'vertices_editor_state.dart'; @@ -7,6 +8,9 @@ part 'vertices_editor_state.dart'; /// {@template vertices_editor_cubit} /// Edits a working copy of the vertices of a polygon, which the vertices /// editor dialog returns when the user applies the changes. +/// +/// Also holds the zoom of the canvas, which is not remembered: the dialog +/// opens fitting the polygon again every time. /// {@endtemplate} class VerticesEditorCubit extends Cubit { /// {@macro vertices_editor_cubit} @@ -64,4 +68,20 @@ class VerticesEditorCubit extends Cubit { ), ); } + + /// Zooms the canvas in to the next of the [ZoomLevels], if any. + void zoomedIn() { + final zoom = ZoomLevels.next(state.zoom); + if (zoom != null) emit(state.copyWith(zoom: zoom)); + } + + /// Zooms the canvas out to the previous of the [ZoomLevels], if any. + void zoomedOut() { + final zoom = ZoomLevels.previous(state.zoom); + if (zoom != null) emit(state.copyWith(zoom: zoom)); + } + + /// Resets the zoom of the canvas, showing the polygon as it was fitted + /// when the dialog opened. + void zoomReset() => emit(state.copyWith(zoom: 1)); } diff --git a/lib/vertices_editor/bloc/vertices_editor_state.dart b/lib/vertices_editor/bloc/vertices_editor_state.dart index 504e4a3..7093ec3 100644 --- a/lib/vertices_editor/bloc/vertices_editor_state.dart +++ b/lib/vertices_editor/bloc/vertices_editor_state.dart @@ -7,6 +7,7 @@ final class VerticesEditorState extends Equatable { required this.initial, List? vertices, this.selectedIndex, + this.zoom = 1, }) : vertices = vertices ?? initial.vertices; /// The vertices the editor opened with. @@ -18,6 +19,16 @@ final class VerticesEditorState extends Equatable { /// The index of the selected vertex in [vertices], if any. final int? selectedIndex; + /// The zoom of the canvas, relative to the scale that fits the polygon + /// in it (see `VerticesViewTransform.fit`), around its center. + final double zoom; + + /// Whether the canvas can zoom in further. + bool get canZoomIn => ZoomLevels.next(zoom) != null; + + /// Whether the canvas can zoom out further. + bool get canZoomOut => ZoomLevels.previous(zoom) != null; + /// Whether a vertex can be deleted: a polygon needs at least /// [ComponentVertices.minLength] vertices. bool get canDelete => vertices.length > ComponentVertices.minLength; @@ -33,6 +44,7 @@ final class VerticesEditorState extends Equatable { VerticesEditorState copyWith({ List? vertices, int? Function()? selectedIndex, + double? zoom, }) { return VerticesEditorState( initial: initial, @@ -40,9 +52,10 @@ final class VerticesEditorState extends Equatable { selectedIndex: selectedIndex != null ? selectedIndex() : this.selectedIndex, + zoom: zoom ?? this.zoom, ); } @override - List get props => [initial, vertices, selectedIndex]; + List get props => [initial, vertices, selectedIndex, zoom]; } diff --git a/lib/vertices_editor/view/vertices_editor_view.dart b/lib/vertices_editor/view/vertices_editor_view.dart index 43ac27e..46a915a 100644 --- a/lib/vertices_editor/view/vertices_editor_view.dart +++ b/lib/vertices_editor/view/vertices_editor_view.dart @@ -1,13 +1,15 @@ import 'package:component_repository/component_repository.dart'; import 'package:flame_component_editor/vertices_editor/bloc/vertices_editor_cubit.dart'; import 'package:flame_component_editor/vertices_editor/widgets/widgets.dart'; +import 'package:flame_component_editor/widgets/widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; /// {@template vertices_editor_view} /// The content of the `VerticesEditorDialog`: the canvas, the list of -/// vertices to type their coordinates, the selected vertex and the buttons -/// that apply or discard the changes. +/// vertices to type their coordinates, the selected vertex, the zoom of +/// the canvas (also Cmd/Ctrl + `=`, `-` and `0`) and the buttons that apply +/// or discard the changes. /// {@endtemplate} class VerticesEditorView extends StatelessWidget { /// {@macro vertices_editor_view} @@ -21,78 +23,102 @@ class VerticesEditorView extends StatelessWidget { final theme = Theme.of(context); final state = context.watch().state; final selectedIndex = state.selectedIndex; + final cubit = context.read(); - return Dialog( - clipBehavior: Clip.antiAlias, - child: SizedBox( - width: 1080, - height: 600, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 12), - child: Text('Edit vertices', style: theme.textTheme.titleLarge), - ), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ClipRect(child: VerticesCanvas(paint: paint)), - ), - const VerticalDivider(width: 1), - const SizedBox(width: 280, child: VerticesList()), - ], + return CallbackShortcuts( + bindings: zoomShortcuts( + onZoomIn: cubit.zoomedIn, + onZoomOut: cubit.zoomedOut, + onReset: cubit.zoomReset, + ), + child: Dialog( + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: 1080, + height: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Edit vertices', + style: theme.textTheme.titleLarge, + ), + ), + ZoomControls( + zoom: state.zoom, + onZoomIn: state.canZoomIn ? cubit.zoomedIn : null, + onZoomOut: state.canZoomOut ? cubit.zoomedOut : null, + onReset: cubit.zoomReset, + ), + ], + ), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ClipRect(child: VerticesCanvas(paint: paint)), + ), + const VerticalDivider(width: 1), + const SizedBox(width: 280, child: VerticesList()), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 16, 16), - child: Row( - spacing: 8, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - selectedIndex == null - ? '${state.vertices.length} vertices' - : _describe( - selectedIndex, - state.vertices[selectedIndex], - ), - ), - Text( - 'Drag to move · click + on an edge to add · ' - 'Delete to remove · type exact values on the right', - style: theme.textTheme.bodySmall, - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 16, 16), + child: Row( + spacing: 8, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedIndex == null + ? '${state.vertices.length} vertices' + : _describe( + selectedIndex, + state.vertices[selectedIndex], + ), + ), + Text( + 'Drag to move · click + on an edge to add · ' + 'Delete to remove · type exact values on the right', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + tooltip: 'Delete vertex', + icon: const Icon(Icons.delete_outline), + onPressed: selectedIndex != null && state.canDelete + ? context + .read() + .selectedVertexDeleted + : null, + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of( + context, + ).pop(context.read().state.result), + child: const Text('Apply'), ), - ), - IconButton( - tooltip: 'Delete vertex', - icon: const Icon(Icons.delete_outline), - onPressed: selectedIndex != null && state.canDelete - ? context - .read() - .selectedVertexDeleted - : null, - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context) - .pop(context.read().state.result), - child: const Text('Apply'), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/vertices_editor/widgets/vertices_canvas.dart b/lib/vertices_editor/widgets/vertices_canvas.dart index 5cf601d..5ef9270 100644 --- a/lib/vertices_editor/widgets/vertices_canvas.dart +++ b/lib/vertices_editor/widgets/vertices_canvas.dart @@ -29,8 +29,9 @@ class VerticesCanvas extends StatefulWidget { class _VerticesCanvasState extends State { final _focusNode = FocusNode(debugLabel: 'VerticesCanvas'); - // Computed once, so the polygon doesn't move while it is edited. - VerticesViewTransform? _transform; + // Computed once, so the polygon doesn't move while it is edited. The + // zoom is applied on top of it. + VerticesViewTransform? _fitted; int? _hoveredEdge; int? _draggedIndex; MouseCursor _cursor = MouseCursor.defer; @@ -147,10 +148,11 @@ class _VerticesCanvasState extends State { color: VerticesCanvas.backgroundColor, child: LayoutBuilder( builder: (context, constraints) { - final transform = _transform ??= VerticesViewTransform.fit( + final size = constraints.biggest; + final transform = (_fitted ??= VerticesViewTransform.fit( state.initial.vertices, - constraints.biggest, - ); + size, + )).zoomed(state.zoom, size.center(Offset.zero)); return MouseRegion( cursor: _cursor, onHover: (event) => _onHover(event, transform), diff --git a/lib/vertices_editor/widgets/vertices_view_transform.dart b/lib/vertices_editor/widgets/vertices_view_transform.dart index aa69a6d..e915ac5 100644 --- a/lib/vertices_editor/widgets/vertices_view_transform.dart +++ b/lib/vertices_editor/widgets/vertices_view_transform.dart @@ -2,11 +2,13 @@ import 'dart:math' as math; import 'dart:ui'; import 'package:component_repository/component_repository.dart'; +import 'package:flutter/foundation.dart'; /// {@template vertices_view_transform} /// Maps the local coordinates of a polygon to the vertices canvas and /// back, and finds the handles under a point of the canvas. /// {@endtemplate} +@immutable class VerticesViewTransform { /// {@macro vertices_view_transform} const new({this.scale = 1, this.offset = Offset.zero}); @@ -34,6 +36,24 @@ class VerticesViewTransform { ); } + /// This transform scaled by [zoom] around [center], a point of the + /// canvas that stays where it is. + VerticesViewTransform zoomed(double zoom, Offset center) { + return VerticesViewTransform( + scale: scale * zoom, + offset: center - (center - offset) * zoom, + ); + } + + @override + bool operator ==(Object other) => + other is VerticesViewTransform && + other.scale == scale && + other.offset == offset; + + @override + int get hashCode => Object.hash(scale, offset); + /// The radius around a handle within which the pointer hits it. static const hitRadius = 10.0; diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart new file mode 100644 index 0000000..6ea3d4f --- /dev/null +++ b/lib/widgets/widgets.dart @@ -0,0 +1 @@ +export 'zoom_controls.dart'; diff --git a/lib/widgets/zoom_controls.dart b/lib/widgets/zoom_controls.dart new file mode 100644 index 0000000..5e0905d --- /dev/null +++ b/lib/widgets/zoom_controls.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// {@template zoom_controls} +/// Zoom out and zoom in buttons around the current [zoom], as a +/// percentage, which resets it to 100% when pressed. +/// +/// A `null` callback disables its button, e.g. at the smallest or largest +/// zoom. +/// {@endtemplate} +class ZoomControls extends StatelessWidget { + /// {@macro zoom_controls} + const new({ + required this.zoom, + required this.onZoomIn, + required this.onZoomOut, + required this.onReset, + super.key, + }); + + /// The current zoom, `1` being the actual size. + final double zoom; + + /// Called when the user zooms in. + final VoidCallback? onZoomIn; + + /// Called when the user zooms out. + final VoidCallback? onZoomOut; + + /// Called when the user resets the zoom to 100%. + final VoidCallback? onReset; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Zoom out', + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.zoom_out), + onPressed: onZoomOut, + ), + Tooltip( + message: 'Reset zoom', + child: TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + minimumSize: const Size(56, 32), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: onReset, + child: Text('${(zoom * 100).round()}%'), + ), + ), + IconButton( + tooltip: 'Zoom in', + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.zoom_in), + onPressed: onZoomIn, + ), + ], + ); + } +} + +/// The zoom keyboard shortcuts, for a `CallbackShortcuts`: Cmd/Ctrl + `=` +/// (or `+`) zooms in, Cmd/Ctrl + `-` zooms out and Cmd/Ctrl + `0` resets +/// the zoom. +Map zoomShortcuts({ + required VoidCallback onZoomIn, + required VoidCallback onZoomOut, + required VoidCallback onReset, +}) { + return { + for (final key in [ + LogicalKeyboardKey.equal, + LogicalKeyboardKey.add, + LogicalKeyboardKey.numpadAdd, + ]) ...{ + SingleActivator(key, meta: true): onZoomIn, + SingleActivator(key, control: true): onZoomIn, + // `+` is Shift+= on most keyboards. + SingleActivator(key, meta: true, shift: true): onZoomIn, + SingleActivator(key, control: true, shift: true): onZoomIn, + }, + for (final key in [ + LogicalKeyboardKey.minus, + LogicalKeyboardKey.numpadSubtract, + ]) ...{ + SingleActivator(key, meta: true): onZoomOut, + SingleActivator(key, control: true): onZoomOut, + }, + for (final key in [ + LogicalKeyboardKey.digit0, + LogicalKeyboardKey.numpad0, + ]) ...{ + SingleActivator(key, meta: true): onReset, + SingleActivator(key, control: true): onReset, + }, + }; +} diff --git a/packages/editor_preferences_repository/.gitignore b/packages/editor_preferences_repository/.gitignore new file mode 100644 index 0000000..aec3808 --- /dev/null +++ b/packages/editor_preferences_repository/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +build/ +coverage/ +pubspec.lock diff --git a/packages/editor_preferences_repository/analysis_options.yaml b/packages/editor_preferences_repository/analysis_options.yaml new file mode 100644 index 0000000..911bffb --- /dev/null +++ b/packages/editor_preferences_repository/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: + - build/** diff --git a/packages/editor_preferences_repository/lib/editor_preferences_repository.dart b/packages/editor_preferences_repository/lib/editor_preferences_repository.dart new file mode 100644 index 0000000..f4de771 --- /dev/null +++ b/packages/editor_preferences_repository/lib/editor_preferences_repository.dart @@ -0,0 +1,6 @@ +/// Repository for the editor preferences, such as the zoom of each +/// component. +library; + +export 'src/editor_preferences_repository.dart'; +export 'src/models/models.dart'; diff --git a/packages/editor_preferences_repository/lib/src/editor_preferences_repository.dart b/packages/editor_preferences_repository/lib/src/editor_preferences_repository.dart new file mode 100644 index 0000000..213b190 --- /dev/null +++ b/packages/editor_preferences_repository/lib/src/editor_preferences_repository.dart @@ -0,0 +1,58 @@ +import 'package:editor_preferences_repository/src/models/models.dart'; +import 'package:key_value_storage_client/key_value_storage_client.dart'; + +/// {@template editor_preferences_repository} +/// Keeps the preferences of the editor, which belong to the app rather than +/// to a project, such as the zoom of the preview of each component. +/// {@endtemplate} +class EditorPreferencesRepository { + /// {@macro editor_preferences_repository} + new({required this._storage}); + + final KeyValueStorageClient _storage; + + /// The zoom of a component whose zoom was never changed. + static const defaultZoom = 1.0; + + /// The smallest zoom. + static const double minZoom = ZoomLevels.min; + + /// The largest zoom. + static const double maxZoom = ZoomLevels.max; + + /// Storage key under which the zoom of the component named + /// [componentName] of the project at [projectPath] is persisted. + static String componentZoomKey(String projectPath, String componentName) => + 'component_zoom:$projectPath:$componentName'; + + /// Returns the zoom of the preview of the component named [componentName] + /// of the project at [projectPath], between [minZoom] and [maxZoom]. + /// + /// Returns [defaultZoom] if nothing was stored or the stored value is + /// unreadable. + Future getComponentZoom({ + required String projectPath, + required String componentName, + }) async { + final raw = await _storage.read( + componentZoomKey(projectPath, componentName), + ); + final zoom = raw == null ? null : double.tryParse(raw); + if (zoom == null || !zoom.isFinite) return defaultZoom; + return zoom.clamp(minZoom, maxZoom); + } + + /// Stores [zoom], clamped between [minZoom] and [maxZoom], as the zoom of + /// the preview of the component named [componentName] of the project at + /// [projectPath]. + Future setComponentZoom({ + required String projectPath, + required String componentName, + required double zoom, + }) { + return _storage.write( + componentZoomKey(projectPath, componentName), + zoom.clamp(minZoom, maxZoom).toString(), + ); + } +} diff --git a/packages/editor_preferences_repository/lib/src/models/models.dart b/packages/editor_preferences_repository/lib/src/models/models.dart new file mode 100644 index 0000000..9a59538 --- /dev/null +++ b/packages/editor_preferences_repository/lib/src/models/models.dart @@ -0,0 +1 @@ +export 'zoom_levels.dart'; diff --git a/packages/editor_preferences_repository/lib/src/models/zoom_levels.dart b/packages/editor_preferences_repository/lib/src/models/zoom_levels.dart new file mode 100644 index 0000000..0b55de8 --- /dev/null +++ b/packages/editor_preferences_repository/lib/src/models/zoom_levels.dart @@ -0,0 +1,44 @@ +/// The zooms an editor canvas steps through when zooming in and out, `1` +/// being the actual size. +abstract final class ZoomLevels { + /// The smallest zoom. + static const min = 0.1; + + /// The largest zoom. + static const max = 8.0; + + /// Every level, from [min] to [max]. + static const List all = [ + min, + 0.25, + 0.5, + 0.75, + 1, + 1.5, + 2, + 3, + 4, + 6, + max, + ]; + + static const _tolerance = 1e-6; + + /// The zoom after zooming in from [zoom], or `null` if it is [max] + /// already. A zoom between two levels goes to the larger one. + static double? next(double zoom) { + for (final level in all) { + if (level > zoom + _tolerance) return level; + } + return null; + } + + /// The zoom after zooming out from [zoom], or `null` if it is [min] + /// already. A zoom between two levels goes to the smaller one. + static double? previous(double zoom) { + for (final level in all.reversed) { + if (level < zoom - _tolerance) return level; + } + return null; + } +} diff --git a/packages/editor_preferences_repository/pubspec.yaml b/packages/editor_preferences_repository/pubspec.yaml new file mode 100644 index 0000000..6ff32cf --- /dev/null +++ b/packages/editor_preferences_repository/pubspec.yaml @@ -0,0 +1,19 @@ +name: editor_preferences_repository +description: Repository for the editor preferences, such as the zoom of each component. +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.13.4 + +dependencies: + flutter: + sdk: flutter + key_value_storage_client: + path: ../key_value_storage_client + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^1.0.5 + very_good_analysis: ^11.0.0 diff --git a/packages/editor_preferences_repository/test/src/editor_preferences_repository_test.dart b/packages/editor_preferences_repository/test/src/editor_preferences_repository_test.dart new file mode 100644 index 0000000..e31caac --- /dev/null +++ b/packages/editor_preferences_repository/test/src/editor_preferences_repository_test.dart @@ -0,0 +1,107 @@ +import 'package:editor_preferences_repository/editor_preferences_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:key_value_storage_client/key_value_storage_client.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockKeyValueStorageClient extends Mock implements KeyValueStorageClient; + +void main() { + group(EditorPreferencesRepository, () { + const projectPath = '/projects/game'; + const componentName = 'player'; + final key = EditorPreferencesRepository.componentZoomKey( + projectPath, + componentName, + ); + + late KeyValueStorageClient storage; + late EditorPreferencesRepository repository; + + setUp(() { + storage = _MockKeyValueStorageClient(); + repository = EditorPreferencesRepository(storage: storage); + + when(() => storage.write(any(), any())).thenAnswer((_) async {}); + }); + + test('componentZoomKey differs per project and component', () { + expect({ + EditorPreferencesRepository.componentZoomKey('/a', 'player'), + EditorPreferencesRepository.componentZoomKey('/b', 'player'), + EditorPreferencesRepository.componentZoomKey('/a', 'enemy'), + }, hasLength(3)); + }); + + group('getComponentZoom', () { + Future get() => repository.getComponentZoom( + projectPath: projectPath, + componentName: componentName, + ); + + test('returns the default zoom when nothing is stored', () async { + when(() => storage.read(key)).thenAnswer((_) async => null); + + expect(await get(), EditorPreferencesRepository.defaultZoom); + }); + + test('returns the stored zoom', () async { + when(() => storage.read(key)).thenAnswer((_) async => '2.5'); + + expect(await get(), 2.5); + }); + + test( + 'returns the default zoom when the stored value is unreadable', + () async { + when(() => storage.read(key)).thenAnswer((_) async => 'big'); + + expect(await get(), EditorPreferencesRepository.defaultZoom); + }, + ); + + test( + 'returns the default zoom when the stored value is not finite', + () async { + when(() => storage.read(key)).thenAnswer((_) async => 'NaN'); + + expect(await get(), EditorPreferencesRepository.defaultZoom); + }, + ); + + test('clamps the stored zoom', () async { + when(() => storage.read(key)).thenAnswer((_) async => '100'); + expect(await get(), EditorPreferencesRepository.maxZoom); + + when(() => storage.read(key)).thenAnswer((_) async => '0'); + expect(await get(), EditorPreferencesRepository.minZoom); + }); + }); + + group('setComponentZoom', () { + test('stores the zoom under the component key', () async { + await repository.setComponentZoom( + projectPath: projectPath, + componentName: componentName, + zoom: 1.5, + ); + + verify(() => storage.write(key, '1.5')).called(1); + }); + + test('clamps the zoom', () async { + await repository.setComponentZoom( + projectPath: projectPath, + componentName: componentName, + zoom: 50, + ); + + verify( + () => storage.write( + key, + EditorPreferencesRepository.maxZoom.toString(), + ), + ).called(1); + }); + }); + }); +} diff --git a/packages/editor_preferences_repository/test/src/models/zoom_levels_test.dart b/packages/editor_preferences_repository/test/src/models/zoom_levels_test.dart new file mode 100644 index 0000000..7072d17 --- /dev/null +++ b/packages/editor_preferences_repository/test/src/models/zoom_levels_test.dart @@ -0,0 +1,25 @@ +import 'package:editor_preferences_repository/editor_preferences_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ZoomLevels, () { + test('levels go from min to max, in order', () { + expect(ZoomLevels.all.first, ZoomLevels.min); + expect(ZoomLevels.all.last, ZoomLevels.max); + expect(ZoomLevels.all, orderedEquals([...ZoomLevels.all]..sort())); + expect(ZoomLevels.all, contains(1)); + }); + + test('next steps to the next level', () { + expect(ZoomLevels.next(1), 1.5); + expect(ZoomLevels.next(1.2), 1.5); + expect(ZoomLevels.next(ZoomLevels.max), isNull); + }); + + test('previous steps to the previous level', () { + expect(ZoomLevels.previous(1), 0.75); + expect(ZoomLevels.previous(1.2), 1); + expect(ZoomLevels.previous(ZoomLevels.min), isNull); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index eb29665..011f4f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -176,6 +176,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + editor_preferences_repository: + dependency: "direct main" + description: + path: "packages/editor_preferences_repository" + relative: true + source: path + version: "0.1.0+1" equatable: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ab376e2..78cf97e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,8 @@ dependencies: bloc: ^9.2.1 component_repository: path: packages/component_repository + editor_preferences_repository: + path: packages/editor_preferences_repository equatable: ^3.0.0 file_system_client: path: packages/file_system_client diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index f18c539..e09f593 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -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/app/app.dart'; import 'package:flame_component_editor/home/home.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,6 +10,9 @@ class _MockProjectRepository extends Mock implements ProjectRepository; class _MockComponentRepository extends Mock implements ComponentRepository; +class _MockEditorPreferencesRepository extends Mock + implements EditorPreferencesRepository; + void main() { group(App, () { testWidgets('renders the home view', (tester) async { @@ -19,6 +23,7 @@ void main() { App( projectRepository: projectRepository, componentRepository: _MockComponentRepository(), + editorPreferencesRepository: _MockEditorPreferencesRepository(), ), ); diff --git a/test/component_editor/bloc/component_editor_bloc_test.dart b/test/component_editor/bloc/component_editor_bloc_test.dart index 5677410..98a1ff8 100644 --- a/test/component_editor/bloc/component_editor_bloc_test.dart +++ b/test/component_editor/bloc/component_editor_bloc_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:component_repository/component_repository.dart'; +import 'package:editor_preferences_repository/editor_preferences_repository.dart'; import 'package:flame_component_editor/component_editor/component_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -11,6 +12,9 @@ class _MockComponentRepository extends Mock implements ComponentRepository; class _MockProjectRepository extends Mock implements ProjectRepository; +class _MockEditorPreferencesRepository extends Mock + implements EditorPreferencesRepository; + void main() { setUpAll( () => registerFallbackValue(const RectangleComponent(name: 'Fallback')), @@ -28,8 +32,27 @@ void main() { late ComponentRepository componentRepository; late ProjectRepository projectRepository; + late EditorPreferencesRepository editorPreferencesRepository; + + void stubStoredZoom(double zoom) { + when( + () => editorPreferencesRepository.getComponentZoom( + projectPath: project.path, + componentName: component.name, + ), + ).thenAnswer((_) async => zoom); + } setUp(() { + editorPreferencesRepository = _MockEditorPreferencesRepository(); + stubStoredZoom(EditorPreferencesRepository.defaultZoom); + when( + () => editorPreferencesRepository.setComponentZoom( + projectPath: any(named: 'projectPath'), + componentName: any(named: 'componentName'), + zoom: any(named: 'zoom'), + ), + ).thenAnswer((_) async {}); componentRepository = _MockComponentRepository(); projectRepository = _MockProjectRepository(); when( @@ -44,6 +67,7 @@ void main() { component: component, componentRepository: componentRepository, projectRepository: projectRepository, + editorPreferencesRepository: editorPreferencesRepository, ); test('initial state has no changes', () { @@ -116,6 +140,7 @@ void main() { component: polygon, componentRepository: componentRepository, projectRepository: projectRepository, + editorPreferencesRepository: editorPreferencesRepository, ), act: (bloc) => bloc.add(const ComponentEditorVerticesChanged(vertices)), expect: () => const [ @@ -145,6 +170,7 @@ void main() { component: circle, componentRepository: componentRepository, projectRepository: projectRepository, + editorPreferencesRepository: editorPreferencesRepository, ), act: (bloc) => bloc.add(const ComponentEditorRadiusChanged(radius)), expect: () => const [ @@ -195,6 +221,114 @@ void main() { ], ); + group('zoom', () { + void verifyZoomStored(double zoom) { + verify( + () => editorPreferencesRepository.setComponentZoom( + projectPath: project.path, + componentName: component.name, + zoom: zoom, + ), + ).called(1); + } + + blocTest( + 'ComponentEditorZoomedIn steps to the next level and stores it', + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorZoomedIn()), + expect: () => [ + isA() + .having((s) => s.zoom, 'zoom', 1.5) + .having((s) => s.hasChanges, 'hasChanges', isFalse) + .having((s) => s.status, 'status', ComponentEditorStatus.ready), + ], + verify: (_) => verifyZoomStored(1.5), + ); + + blocTest( + 'ComponentEditorZoomedOut steps to the previous level and stores it', + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorZoomedOut()), + expect: () => [ + isA().having((s) => s.zoom, 'zoom', 0.75), + ], + verify: (_) => verifyZoomStored(0.75), + ); + + blocTest( + 'steps from a zoom between levels to the nearest ones', + build: buildBloc, + seed: () => const ComponentEditorState( + component: component, + savedComponent: component, + zoom: 1.2, + ), + act: (bloc) => bloc + ..add(const ComponentEditorZoomedIn()) + ..add(const ComponentEditorZoomReset()) + ..add(const ComponentEditorZoomedOut()), + expect: () => [ + isA().having((s) => s.zoom, 'zoom', 1.5), + isA().having((s) => s.zoom, 'zoom', 1), + isA().having((s) => s.zoom, 'zoom', 0.75), + ], + ); + + blocTest( + 'does not zoom past the largest and smallest levels', + build: buildBloc, + seed: () => const ComponentEditorState( + component: component, + savedComponent: component, + zoom: EditorPreferencesRepository.maxZoom, + ), + act: (bloc) => bloc.add(const ComponentEditorZoomedIn()), + expect: () => const [], + verify: (bloc) { + expect(bloc.state.nextZoom, isNull); + expect( + const ComponentEditorState( + component: component, + savedComponent: component, + zoom: EditorPreferencesRepository.minZoom, + ).previousZoom, + isNull, + ); + }, + ); + + blocTest( + 'ComponentEditorZoomReset goes back to 100% and stores it', + build: buildBloc, + seed: () => const ComponentEditorState( + component: component, + savedComponent: component, + zoom: 3, + ), + act: (bloc) => bloc.add(const ComponentEditorZoomReset()), + expect: () => const [ + ComponentEditorState(component: component, savedComponent: component), + ], + verify: (_) => verifyZoomStored(1), + ); + + blocTest( + 'still zooms when the zoom cannot be stored', + setUp: () => when( + () => editorPreferencesRepository.setComponentZoom( + projectPath: any(named: 'projectPath'), + componentName: any(named: 'componentName'), + zoom: any(named: 'zoom'), + ), + ).thenThrow(Exception('oops')), + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorZoomedIn()), + expect: () => [ + isA().having((s) => s.zoom, 'zoom', 1.5), + ], + ); + }); + test('rootClassOwner is the other component generating the root ' 'class', () { const aB = RectangleComponent(name: 'a_b'); @@ -237,6 +371,66 @@ void main() { ).thenAnswer((_) async => isUpToDate); } + blocTest( + 'loads the stored zoom of the component', + setUp: () { + stubStoredZoom(2); + stubComponents(const []); + stubUpToDate(isUpToDate: true); + }, + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorStarted()), + expect: () => const [ + ComponentEditorState( + component: component, + savedComponent: component, + zoom: 2, + ), + ], + ); + + blocTest( + 'keeps a zoom changed while the stored one loads', + setUp: () { + final completer = Completer(); + when( + () => editorPreferencesRepository.getComponentZoom( + projectPath: project.path, + componentName: component.name, + ), + ).thenAnswer((_) => completer.future); + stubComponents(const []); + stubUpToDate(isUpToDate: true); + Future.delayed(Duration.zero, () => completer.complete(4)); + }, + build: buildBloc, + act: (bloc) => bloc + ..add(const ComponentEditorStarted()) + ..add(const ComponentEditorZoomedIn()), + wait: const Duration(milliseconds: 10), + expect: () => [ + isA().having((s) => s.zoom, 'zoom', 1.5), + ], + ); + + blocTest( + 'keeps the default zoom when the stored one cannot be loaded', + setUp: () { + when( + () => editorPreferencesRepository.getComponentZoom( + projectPath: project.path, + componentName: component.name, + ), + ).thenThrow(Exception('oops')); + stubComponents(const []); + stubUpToDate(isUpToDate: true); + }, + build: buildBloc, + act: (bloc) => bloc.add(const ComponentEditorStarted()), + verify: (bloc) => + expect(bloc.state.zoom, EditorPreferencesRepository.defaultZoom), + ); + blocTest( 'loads the classes of the other components', setUp: () { diff --git a/test/component_editor/view/component_editor_page_test.dart b/test/component_editor/view/component_editor_page_test.dart index 5ceb4de..ba02a62 100644 --- a/test/component_editor/view/component_editor_page_test.dart +++ b/test/component_editor/view/component_editor_page_test.dart @@ -238,6 +238,97 @@ void main() { await states.close(); }); + group('zoom', () { + testWidgets('dispatches zooms from the toolbar', (tester) async { + await tester.pumpApp(buildSubject()); + + await tester.tap(find.byTooltip('Zoom in')); + await tester.tap(find.byTooltip('Zoom out')); + await tester.tap(find.text('100%')); + + verify(() => bloc.add(const ComponentEditorZoomedIn())).called(1); + verify(() => bloc.add(const ComponentEditorZoomedOut())).called(1); + verify(() => bloc.add(const ComponentEditorZoomReset())).called(1); + }); + + testWidgets('shows the zoom and disables zooming past the limits', ( + tester, + ) async { + when(() => bloc.state).thenReturn( + const ComponentEditorState( + component: component, + savedComponent: component, + zoom: 8, + ), + ); + await tester.pumpApp(buildSubject()); + + expect(find.text('800%'), findsOneWidget); + IconButton button(String tooltip) => tester.widget( + find.ancestor( + of: find.byTooltip(tooltip), + matching: find.byType(IconButton), + ), + ); + expect(button('Zoom in').onPressed, isNull); + expect(button('Zoom out').onPressed, isNotNull); + }); + + for (final (control, key, event) in [ + ( + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.equal, + const ComponentEditorZoomedIn(), + ), + ( + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.minus, + const ComponentEditorZoomedOut(), + ), + ( + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.digit0, + const ComponentEditorZoomReset(), + ), + ]) { + testWidgets('dispatches $event with ${control.keyLabel}+' + '${key.keyLabel}', (tester) async { + await tester.pumpApp(buildSubject()); + await tester.pump(); + + await tester.sendKeyDownEvent(control); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(control); + + verify(() => bloc.add(event)).called(1); + }); + } + + testWidgets('zooms the preview', (tester) async { + const state = ComponentEditorState( + component: component, + savedComponent: component, + zoom: 2, + ); + 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.camera.viewfinder.zoom, 2); + + states.add(state.copyWith(zoom: 0.5)); + await tester.pump(); + + expect(game.camera.viewfinder.zoom, 0.5); + 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 12793ce..7d48a69 100644 --- a/test/component_editor/widgets/preview/component_preview_game_test.dart +++ b/test/component_editor/widgets/preview/component_preview_game_test.dart @@ -11,6 +11,7 @@ class _MockCanvas extends Mock implements Canvas; void main() { setUpAll(() { registerFallbackValue(Offset.zero); + registerFallbackValue(Rect.zero); registerFallbackValue(Paint()); }); @@ -332,6 +333,40 @@ void main() { ); }); + group('zoom', () { + testWithGame( + 'zooms the camera when loaded', + () => ComponentPreviewGame(component: component, zoom: 2), + (game) async { + await game.ready(); + + expect(game.zoom, 2); + expect(game.camera.viewfinder.zoom, 2); + expect(game.highlight.zoom, 2); + }, + ); + + testWithGame( + 'zooms the camera when changed', + () => ComponentPreviewGame(component: component), + (game) async { + await game.ready(); + + game.zoom = 4; + + expect(game.camera.viewfinder.zoom, 4); + expect(game.highlight.zoom, 4); + }, + ); + + test('keeps the zoom for later when changed before loading', () { + final game = ComponentPreviewGame(component: component)..zoom = 3; + + expect(game.zoom, 3); + expect(game.isLoaded, isFalse); + }); + }); + test('haveSameShape compares the types and the tree', () { const child = RectangleComponent(name: 'rectangle1'); @@ -395,6 +430,38 @@ void main() { }); }); + group(SelectionHighlight, () { + test('keeps the same border width on screen at any zoom', () { + final highlight = SelectionHighlight(); + expect(highlight.worldStrokeWidth, SelectionHighlight.strokeWidth); + + highlight.zoom = 2; + + expect(highlight.worldStrokeWidth, SelectionHighlight.strokeWidth / 2); + }); + + testWithGame( + 'draws the border with the zoomed width', + () => ComponentPreviewGame( + component: component, + highlightedPath: [], + zoom: 2, + ), + (game) async { + await game.ready(); + final canvas = _MockCanvas(); + + game.highlight.render(canvas); + + final captured = verify( + () => canvas.drawRect(captureAny(), captureAny()), + ).captured; + expect(captured[0], const Rect.fromLTWH(0, 0, 10, 20).inflate(1)); + expect((captured[1] as Paint).strokeWidth, 2); + }, + ); + }); + group(PreviewPositionComponent, () { test('draws a dashed outline around its size', () { final canvas = _MockCanvas(); diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index accd320..c316cf3 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -1,4 +1,5 @@ import 'package:component_repository/component_repository.dart'; +import 'package:editor_preferences_repository/editor_preferences_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,6 +10,25 @@ class _MockProjectRepository extends Mock implements ProjectRepository; class _MockComponentRepository extends Mock implements ComponentRepository; +class _MockEditorPreferencesRepository extends Mock + implements EditorPreferencesRepository { + new() { + when( + () => getComponentZoom( + projectPath: any(named: 'projectPath'), + componentName: any(named: 'componentName'), + ), + ).thenAnswer((_) async => EditorPreferencesRepository.defaultZoom); + when( + () => setComponentZoom( + projectPath: any(named: 'projectPath'), + componentName: any(named: 'componentName'), + zoom: any(named: 'zoom'), + ), + ).thenAnswer((_) async {}); + } +} + extension PumpApp on WidgetTester { /// Pumps [widget] wrapped in the app-level dependencies (theme, providers). /// @@ -18,6 +38,7 @@ extension PumpApp on WidgetTester { Widget widget, { ProjectRepository? projectRepository, ComponentRepository? componentRepository, + EditorPreferencesRepository? editorPreferencesRepository, }) { return pumpWidget( MultiRepositoryProvider( @@ -28,6 +49,11 @@ extension PumpApp on WidgetTester { RepositoryProvider.value( value: componentRepository ?? _MockComponentRepository(), ), + RepositoryProvider.value( + value: + editorPreferencesRepository ?? + _MockEditorPreferencesRepository(), + ), ], child: MaterialApp(home: widget), ), diff --git a/test/vertices_editor/bloc/vertices_editor_cubit_test.dart b/test/vertices_editor/bloc/vertices_editor_cubit_test.dart index 445aae3..21b977b 100644 --- a/test/vertices_editor/bloc/vertices_editor_cubit_test.dart +++ b/test/vertices_editor/bloc/vertices_editor_cubit_test.dart @@ -148,5 +148,64 @@ void main() { expect(cubit.state.hasChanges, isFalse); }); + + group('zoom', () { + test('starts at 100%', () { + final state = VerticesEditorCubit(square).state; + + expect(state.zoom, equals(1)); + expect(state.canZoomIn, isTrue); + expect(state.canZoomOut, isTrue); + }); + + blocTest( + 'zoomedIn, zoomedOut and zoomReset change the zoom', + build: () => VerticesEditorCubit(square), + act: (cubit) => cubit + ..zoomedIn() + ..zoomedIn() + ..zoomReset() + ..zoomedOut(), + expect: () => [ + isA().having((s) => s.zoom, 'zoom', 1.5), + isA().having((s) => s.zoom, 'zoom', 2), + isA().having((s) => s.zoom, 'zoom', 1), + isA().having((s) => s.zoom, 'zoom', 0.75), + ], + ); + + blocTest( + 'does not zoom past the largest and smallest levels', + build: () => VerticesEditorCubit(square), + seed: () => VerticesEditorState(initial: square, zoom: 8), + act: (cubit) => cubit.zoomedIn(), + expect: () => const [], + verify: (cubit) { + expect(cubit.state.canZoomIn, isFalse); + expect( + VerticesEditorState(initial: square, zoom: 0.1).canZoomOut, + isFalse, + ); + }, + ); + + blocTest( + 'zooming keeps the edits', + build: () => VerticesEditorCubit(square), + act: (cubit) => cubit + ..vertexMoved(0, 5, 5) + ..zoomedIn(), + skip: 1, + expect: () => [ + isA() + .having((s) => s.zoom, 'zoom', 1.5) + .having( + (s) => s.vertices.first, + 'vertex', + const ComponentVertex(5, 5), + ), + ], + ); + }); }); } diff --git a/test/vertices_editor/view/vertices_editor_dialog_test.dart b/test/vertices_editor/view/vertices_editor_dialog_test.dart index 1d916cf..0007162 100644 --- a/test/vertices_editor/view/vertices_editor_dialog_test.dart +++ b/test/vertices_editor/view/vertices_editor_dialog_test.dart @@ -80,6 +80,74 @@ void main() { expect(result, isNull); }); + group('zoom', () { + VerticesViewTransform transform(WidgetTester tester) => + (tester.widget(canvas()).painter! as VerticesPainter) + .transform; + + testWidgets('zooms the canvas with the buttons', (tester) async { + await openDialog(tester); + final fitted = transform(tester); + expect(find.text('100%'), findsOneWidget); + + await tester.tap(find.byTooltip('Zoom in')); + await tester.pump(); + expect(find.text('150%'), findsOneWidget); + expect(transform(tester).scale, equals(fitted.scale * 1.5)); + + await tester.tap(find.byTooltip('Zoom out')); + await tester.tap(find.byTooltip('Zoom out')); + await tester.pump(); + expect(find.text('75%'), findsOneWidget); + + await tester.tap(find.text('75%')); + await tester.pump(); + expect(transform(tester), equals(fitted)); + }); + + testWidgets('zooms the canvas with the keyboard', (tester) async { + await openDialog(tester); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.equal); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.pump(); + expect(find.text('150%'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.minus); + await tester.sendKeyEvent(LogicalKeyboardKey.minus); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(find.text('75%'), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.digit0); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.pump(); + expect(find.text('100%'), findsOneWidget); + }); + + testWidgets('drags vertices on the zoomed canvas', (tester) async { + await openDialog(tester); + await tester.tap(find.byTooltip('Zoom in')); + await tester.tap(find.byTooltip('Zoom in')); + await tester.pump(); + final zoomed = transform(tester); + + // 2x zoom: 60 pixels are 30 local units. + await tester.dragFrom( + tester.getTopLeft(canvas()) + + zoomed.toCanvas(const ComponentVertex(50, 50)), + const Offset(60, 20), + ); + await tester.pump(); + await apply(tester); + + expect(result!.vertices[2], equals(const ComponentVertex(80, 60))); + }); + }); + testWidgets('selects a vertex when it is tapped', (tester) async { await openDialog(tester); diff --git a/test/vertices_editor/widgets/vertices_view_transform_test.dart b/test/vertices_editor/widgets/vertices_view_transform_test.dart index d91ec37..5a769fe 100644 --- a/test/vertices_editor/widgets/vertices_view_transform_test.dart +++ b/test/vertices_editor/widgets/vertices_view_transform_test.dart @@ -44,6 +44,18 @@ void main() { ); }); + test('zoomed scales around the center, which stays in place', () { + const transform = VerticesViewTransform(scale: 2, offset: Offset(10, 20)); + const center = Offset(100, 100); + final zoomed = transform.zoomed(3, center); + + expect(zoomed.scale, equals(6)); + // The local point under the center doesn't move. + final local = (center - transform.offset) / transform.scale; + expect(zoomed.toCanvas(ComponentVertex(local.dx, local.dy)), center); + expect(transform.zoomed(1, center), equals(transform)); + }); + group('hit testing', () { const transform = VerticesViewTransform(); diff --git a/test/widgets/zoom_controls_test.dart b/test/widgets/zoom_controls_test.dart new file mode 100644 index 0000000..93f4a3a --- /dev/null +++ b/test/widgets/zoom_controls_test.dart @@ -0,0 +1,98 @@ +import 'package:flame_component_editor/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(ZoomControls, () { + Future pump( + WidgetTester tester, { + double zoom = 1, + VoidCallback? onZoomIn, + VoidCallback? onZoomOut, + VoidCallback? onReset, + }) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ZoomControls( + zoom: zoom, + onZoomIn: onZoomIn, + onZoomOut: onZoomOut, + onReset: onReset, + ), + ), + ), + ); + } + + testWidgets('shows the zoom as a percentage', (tester) async { + await pump(tester, zoom: 0.25); + + expect(find.text('25%'), findsOneWidget); + }); + + testWidgets('calls the callbacks', (tester) async { + final calls = []; + await pump( + tester, + onZoomIn: () => calls.add('in'), + onZoomOut: () => calls.add('out'), + onReset: () => calls.add('reset'), + ); + + await tester.tap(find.byTooltip('Zoom in')); + await tester.tap(find.byTooltip('Zoom out')); + await tester.tap(find.text('100%')); + + expect(calls, equals(['in', 'out', 'reset'])); + }); + + testWidgets('disables the buttons without callbacks', (tester) async { + await pump(tester); + + for (final tooltip in ['Zoom in', 'Zoom out']) { + final button = tester.widget( + find.ancestor( + of: find.byTooltip(tooltip), + matching: find.byType(IconButton), + ), + ); + expect(button.onPressed, isNull); + } + }); + }); + + group('zoomShortcuts', () { + testWidgets('binds Cmd and Ctrl with =, +, - and 0', (tester) async { + final calls = []; + await tester.pumpWidget( + CallbackShortcuts( + bindings: zoomShortcuts( + onZoomIn: () => calls.add('in'), + onZoomOut: () => calls.add('out'), + onReset: () => calls.add('reset'), + ), + child: const Focus(autofocus: true, child: SizedBox()), + ), + ); + + for (final modifier in [ + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.controlLeft, + ]) { + await tester.sendKeyDownEvent(modifier); + await tester.sendKeyEvent(LogicalKeyboardKey.equal); + await tester.sendKeyEvent(LogicalKeyboardKey.minus); + await tester.sendKeyEvent(LogicalKeyboardKey.digit0); + await tester.sendKeyEvent(LogicalKeyboardKey.numpadAdd); + await tester.sendKeyUpEvent(modifier); + } + + expect( + calls, + equals(['in', 'out', 'reset', 'in', 'in', 'out', 'reset', 'in']), + ); + }); + }); +}