diff --git a/lib/core/commands/command_factory/command_factory.dart b/lib/core/commands/command_factory/command_factory.dart index 202d9010..01a9bed5 100644 --- a/lib/core/commands/command_factory/command_factory.dart +++ b/lib/core/commands/command_factory/command_factory.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/clipboard_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/delete_region_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/text_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/line_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; @@ -136,4 +137,13 @@ class CommandFactory { Paint(), region, ); + + FillCommand createFillCommand( + Paint paint, + Uint8List imageData, + ) => + FillCommand( + paint, + imageData, + ); } diff --git a/lib/core/commands/command_implementation/command.dart b/lib/core/commands/command_implementation/command.dart index 3265bfb8..613ba633 100644 --- a/lib/core/commands/command_implementation/command.dart +++ b/lib/core/commands/command_implementation/command.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/clipboard_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/delete_region_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/line_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/shape/ellipse_shape_command.dart'; @@ -34,6 +35,8 @@ abstract class Command with EquatableMixin { return DeleteRegionCommand.fromJson(json); case SerializerType.TEXT_COMMAND: return TextCommand.fromJson(json); + case SerializerType.FILL_COMMAND: + return FillCommand.fromJson(json); case SerializerType.HEART_SHAPE_COMMAND: return HeartShapeCommand.fromJson(json); case SerializerType.STAR_SHAPE_COMMAND: diff --git a/lib/core/commands/command_implementation/graphic/fill_command.dart b/lib/core/commands/command_implementation/graphic/fill_command.dart new file mode 100644 index 00000000..10a9182a --- /dev/null +++ b/lib/core/commands/command_implementation/graphic/fill_command.dart @@ -0,0 +1,68 @@ +// ignore_for_file: must_be_immutable + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:paintroid/core/commands/command_implementation/graphic/graphic_command.dart'; +import 'package:paintroid/core/json_serialization/converter/paint_converter.dart'; +import 'package:paintroid/core/json_serialization/converter/uint8list_base64_converter.dart'; +import 'package:paintroid/core/json_serialization/versioning/serializer_version.dart'; +import 'package:paintroid/core/json_serialization/versioning/version_strategy.dart'; + +class FillCommand extends GraphicCommand { + final Uint8List imageData; + final int version; + final String type; + + ui.Image? _runtimeImage; + + FillCommand( + super.paint, + this.imageData, { + int? version, + this.type = SerializerType.FILL_COMMAND, + }) : version = + version ?? VersionStrategyManager.strategy.getFillCommandVersion(); + + @override + Future prepareForRuntime() async { + if (_runtimeImage != null || imageData.isEmpty) { + return; + } + final buffer = await ui.ImmutableBuffer.fromUint8List(imageData); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + final codec = await descriptor.instantiateCodec(); + final frameInfo = await codec.getNextFrame(); + _runtimeImage = frameInfo.image; + } + + @override + void call(ui.Canvas canvas) { + if (_runtimeImage == null) { + return; + } + canvas.drawImage(_runtimeImage!, ui.Offset.zero, ui.Paint()); + } + + @override + Map toJson() { + return { + 'paint': const PaintConverter().toJson(paint), + 'imageData': const Uint8ListBase64Converter().toJson(imageData), + 'version': version, + 'type': type, + }; + } + + factory FillCommand.fromJson(Map json) { + return FillCommand( + const PaintConverter().fromJson(json['paint'] as Map), + const Uint8ListBase64Converter().fromJson(json['imageData'] as String), + version: json['version'] as int? ?? Version.v1, + type: json['type'] as String? ?? SerializerType.FILL_COMMAND, + ); + } + + @override + List get props => [paint, imageData, version, type]; +} diff --git a/lib/core/commands/command_manager/command_manager.dart b/lib/core/commands/command_manager/command_manager.dart index ffa1ce97..657d22bf 100644 --- a/lib/core/commands/command_manager/command_manager.dart +++ b/lib/core/commands/command_manager/command_manager.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:paintroid/core/commands/command_implementation/command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/clipboard_command.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/text_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/graphic_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/line_command.dart'; @@ -123,6 +124,8 @@ class CommandManager { return ToolData.TEXT; } else if (command.runtimeType == SprayCommand) { return ToolData.SPRAY; + } else if (command.runtimeType == FillCommand) { + return ToolData.FILL; } else if (command.runtimeType == StarShapeCommand) { return ToolData.SHAPES; } else if (command.runtimeType == HeartShapeCommand) { diff --git a/lib/core/json_serialization/versioning/serializer_version.dart b/lib/core/json_serialization/versioning/serializer_version.dart index de4c3048..fc27fcc8 100644 --- a/lib/core/json_serialization/versioning/serializer_version.dart +++ b/lib/core/json_serialization/versioning/serializer_version.dart @@ -11,6 +11,7 @@ class SerializerVersion { static const int SPRAY_COMMAND_VERSION = Version.v1; static const int CLIPBOARD_COMMAND_VERSION = Version.v1; static const int DELETE_REGION_COMMAND_VERSION = Version.v1; + static const int FILL_COMMAND_VERSION = Version.v1; } class Version { @@ -33,4 +34,5 @@ class SerializerType { static const String SPRAY_COMMAND = 'SprayCommand'; static const String CLIPBOARD_COMMAND = 'ClipboardCommand'; static const String DELETE_REGION_COMMAND = 'DeleteRegionCommand'; + static const String FILL_COMMAND = 'FillCommand'; } diff --git a/lib/core/json_serialization/versioning/version_strategy.dart b/lib/core/json_serialization/versioning/version_strategy.dart index 2c3d843f..6ffbd606 100644 --- a/lib/core/json_serialization/versioning/version_strategy.dart +++ b/lib/core/json_serialization/versioning/version_strategy.dart @@ -22,6 +22,8 @@ abstract class IVersionStrategy { int getClipboardCommandVersion(); int getDeleteRegionCommandVersion(); + + int getFillCommandVersion(); } class ProductionVersionStrategy implements IVersionStrategy { @@ -63,6 +65,9 @@ class ProductionVersionStrategy implements IVersionStrategy { @override int getDeleteRegionCommandVersion() => SerializerVersion.DELETE_REGION_COMMAND_VERSION; + + @override + int getFillCommandVersion() => SerializerVersion.FILL_COMMAND_VERSION; } class VersionStrategyManager { diff --git a/lib/core/providers/object/tools/fill_tool_provider.dart b/lib/core/providers/object/tools/fill_tool_provider.dart new file mode 100644 index 00000000..f0d2468b --- /dev/null +++ b/lib/core/providers/object/tools/fill_tool_provider.dart @@ -0,0 +1,52 @@ +import 'dart:ui' as ui; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:paintroid/core/commands/command_factory/command_factory_provider.dart'; +import 'package:paintroid/core/commands/command_manager/command_manager_provider.dart'; +import 'package:paintroid/core/commands/graphic_factory/graphic_factory_provider.dart'; +import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:paintroid/core/providers/object/canvas_painter_provider.dart'; +import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; +import 'package:paintroid/core/tools/implementation/fill_tool.dart'; + +final fillToolProvider = Provider((ref) { + Future sourceImageProvider() async { + final canvasState = ref.read(canvasStateProvider); + final size = canvasState.size; + if (size.width <= 0 || size.height <= 0) { + return null; + } + + final recorder = ref.read(graphicFactoryProvider).createPictureRecorder(); + final canvas = + ref.read(graphicFactoryProvider).createCanvasWithRecorder(recorder); + final bounds = ui.Rect.fromLTWH(0, 0, size.width, size.height); + canvas.clipRect(bounds); + + if (canvasState.backgroundImage != null) { + canvas.drawImage( + canvasState.backgroundImage!, ui.Offset.zero, ui.Paint()); + } + if (canvasState.cachedImage != null) { + canvas.drawImage(canvasState.cachedImage!, ui.Offset.zero, ui.Paint()); + } + + final picture = recorder.endRecording(); + return picture.toImage(size.width.toInt(), size.height.toInt()); + } + + Future onFillApplied() async { + await ref.read(canvasStateProvider.notifier).updateCachedImage(); + ref.read(canvasPainterProvider.notifier).repaint(); + } + + return FillTool( + commandManager: ref.watch(commandManagerProvider), + commandFactory: ref.watch(commandFactoryProvider), + graphicFactory: ref.watch(graphicFactoryProvider), + type: ToolType.FILL, + getSourceImage: sourceImageProvider, + onFillApplied: onFillApplied, + ); +}); diff --git a/lib/core/providers/state/toolbox_state_provider.dart b/lib/core/providers/state/toolbox_state_provider.dart index e051305a..9b40fb98 100644 --- a/lib/core/providers/state/toolbox_state_provider.dart +++ b/lib/core/providers/state/toolbox_state_provider.dart @@ -6,6 +6,7 @@ import 'package:paintroid/core/providers/object/canvas_painter_provider.dart'; import 'package:paintroid/core/providers/object/tools/brush_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/clipboard_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/eraser_tool_provider.dart'; +import 'package:paintroid/core/providers/object/tools/fill_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/hand_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/line_tool_provider.dart'; import 'package:paintroid/core/providers/object/tools/shapes_tool_provider.dart'; @@ -77,6 +78,9 @@ class ToolBoxStateProvider extends _$ToolBoxStateProvider { state = state.copyWith(currentTool: ref.read(shapesToolProvider)); ref.read(canvasPainterProvider.notifier).repaint(); break; + case ToolType.FILL: + state = state.copyWith(currentTool: ref.read(fillToolProvider)); + break; case ToolType.TEXT: state = state.copyWith(currentTool: ref.read(textToolProvider)); break; diff --git a/lib/core/tools/implementation/fill_tool.dart b/lib/core/tools/implementation/fill_tool.dart new file mode 100644 index 00000000..9b566911 --- /dev/null +++ b/lib/core/tools/implementation/fill_tool.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:paintroid/core/commands/graphic_factory/graphic_factory.dart'; +import 'package:paintroid/core/tools/tool.dart'; + +class FillTool extends Tool { + final GraphicFactory graphicFactory; + final Future Function() getSourceImage; + final Future Function() onFillApplied; + bool _isFilling = false; + + FillTool({ + required super.commandFactory, + required super.commandManager, + required super.type, + required this.graphicFactory, + required this.getSourceImage, + required this.onFillApplied, + super.hasAddFunctionality = false, + super.hasFinalizeFunctionality = false, + }); + + @override + void onDown(ui.Offset point, ui.Paint paint) { + if (_isFilling) { + return; + } + _isFilling = true; + unawaited(_fill(point, paint)); + } + + Future _fill(ui.Offset point, ui.Paint paint) async { + try { + final sourceImage = await getSourceImage(); + if (sourceImage == null) { + return; + } + + final width = sourceImage.width; + final height = sourceImage.height; + if (width <= 0 || height <= 0) { + return; + } + + final startX = point.dx.floor(); + final startY = point.dy.floor(); + if (startX < 0 || startX >= width || startY < 0 || startY >= height) { + return; + } + + final sourceData = + await sourceImage.toByteData(format: ui.ImageByteFormat.rawRgba); + if (sourceData == null) { + return; + } + final pixels = sourceData.buffer.asUint8List(); + + final startIndex = (startY * width + startX) * 4; + final sourceR = pixels[startIndex]; + final sourceG = pixels[startIndex + 1]; + final sourceB = pixels[startIndex + 2]; + final sourceA = pixels[startIndex + 3]; + + final targetColor = paint.color; + final targetR = (targetColor.r * 255).round(); + final targetG = (targetColor.g * 255).round(); + final targetB = (targetColor.b * 255).round(); + final targetA = (targetColor.a * 255).round(); + + if (sourceR == targetR && + sourceG == targetG && + sourceB == targetB && + sourceA == targetA) { + return; + } + + final queue = Queue()..add(startY * width + startX); + while (queue.isNotEmpty) { + final pixelPos = queue.removeFirst(); + final x = pixelPos % width; + final y = pixelPos ~/ width; + final index = pixelPos * 4; + + if (pixels[index] != sourceR || + pixels[index + 1] != sourceG || + pixels[index + 2] != sourceB || + pixels[index + 3] != sourceA) { + continue; + } + + pixels[index] = targetR; + pixels[index + 1] = targetG; + pixels[index + 2] = targetB; + pixels[index + 3] = targetA; + + if (x > 0) queue.add(pixelPos - 1); + if (x < width - 1) queue.add(pixelPos + 1); + if (y > 0) queue.add(pixelPos - width); + if (y < height - 1) queue.add(pixelPos + width); + } + + final filledImage = await _decodeFromRgba( + Uint8List.fromList(pixels), + width, + height, + ); + final pngData = + await filledImage.toByteData(format: ui.ImageByteFormat.png); + if (pngData == null) { + return; + } + + final savedPaint = graphicFactory.copyPaint(paint); + final command = commandFactory.createFillCommand( + savedPaint, + pngData.buffer.asUint8List(), + ); + await command.prepareForRuntime(); + commandManager.addGraphicCommand(command); + await onFillApplied(); + } finally { + _isFilling = false; + } + } + + Future _decodeFromRgba(Uint8List rgba, int width, int height) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + @override + void onDrag(ui.Offset point, ui.Paint paint) {} + + @override + void onUp(ui.Offset point, ui.Paint paint) {} + + @override + void onCancel() { + _isFilling = false; + } + + @override + void onCheckmark(ui.Paint paint) {} + + @override + void onPlus() {} + + @override + void onUndo() { + commandManager.undo(); + } + + @override + void onRedo() { + commandManager.redo(); + } +} diff --git a/test/unit/command/command_factory_test.dart b/test/unit/command/command_factory_test.dart index 95f952dd..429a104d 100644 --- a/test/unit/command/command_factory_test.dart +++ b/test/unit/command/command_factory_test.dart @@ -1,8 +1,10 @@ +import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:paintroid/core/commands/command_factory/command_factory.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_command.dart'; import 'package:paintroid/core/commands/command_implementation/graphic/path_command.dart'; import 'package:paintroid/core/commands/path_with_action_history.dart'; @@ -23,4 +25,11 @@ void main() { expect(command, isA()); expect(command, equals(expected)); }); + + test('Should return a valid instance of FillCommand', () { + final imageData = Uint8List.fromList([137, 80, 78, 71]); + final command = sut.createFillCommand(testPaint, imageData); + expect(command, isA()); + expect(command.imageData, equals(imageData)); + }); } diff --git a/test/unit/serialization/utils/dummy_version_strategy.dart b/test/unit/serialization/utils/dummy_version_strategy.dart index e1a78232..43f0f2d4 100644 --- a/test/unit/serialization/utils/dummy_version_strategy.dart +++ b/test/unit/serialization/utils/dummy_version_strategy.dart @@ -13,6 +13,7 @@ class DummyVersionStrategy implements IVersionStrategy { final int clipboardCommandVersion; final int deleteRegionCommandVersion; final int textCommandVersion; + final int fillCommandVersion; DummyVersionStrategy({ this.pathCommandVersion = SerializerVersion.PATH_COMMAND_VERSION, @@ -30,6 +31,7 @@ class DummyVersionStrategy implements IVersionStrategy { this.deleteRegionCommandVersion = SerializerVersion.DELETE_REGION_COMMAND_VERSION, this.textCommandVersion = SerializerVersion.TEXT_COMMAND_VERSION, + this.fillCommandVersion = SerializerVersion.FILL_COMMAND_VERSION, }); @override @@ -64,4 +66,7 @@ class DummyVersionStrategy implements IVersionStrategy { @override int getTextCommandVersion() => textCommandVersion; + + @override + int getFillCommandVersion() => fillCommandVersion; } diff --git a/test/unit/tools/fill_tool_test.dart b/test/unit/tools/fill_tool_test.dart new file mode 100644 index 00000000..40997d54 --- /dev/null +++ b/test/unit/tools/fill_tool_test.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:paintroid/core/commands/command_factory/command_factory.dart'; +import 'package:paintroid/core/commands/command_implementation/graphic/fill_command.dart'; +import 'package:paintroid/core/commands/command_manager/command_manager.dart'; +import 'package:paintroid/core/commands/graphic_factory/graphic_factory.dart'; +import 'package:paintroid/core/enums/tool_types.dart'; +import 'package:paintroid/core/tools/implementation/fill_tool.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CommandManager commandManager; + late FillTool sut; + late ui.Paint paint; + + Future imageFromRgba(Uint8List rgba, int width, int height) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + Future imageFromPng(Uint8List pngBytes) async { + final buffer = await ui.ImmutableBuffer.fromUint8List(pngBytes); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + final codec = await descriptor.instantiateCodec(); + final frameInfo = await codec.getNextFrame(); + return frameInfo.image; + } + + int rgbaIndex(int x, int y, int width) => (y * width + x) * 4; + + setUp(() { + commandManager = CommandManager(); + paint = ui.Paint()..color = const ui.Color(0xFF000000); + }); + + test('Does not add command when source image is null', () async { + sut = FillTool( + commandFactory: const CommandFactory(), + commandManager: commandManager, + graphicFactory: const GraphicFactory(), + type: ToolType.FILL, + getSourceImage: () async => null, + onFillApplied: () async {}, + ); + + sut.onDown(const ui.Offset(0, 0), paint); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(commandManager.undoStack, isEmpty); + }); + + test('Adds FillCommand when fill is applied', () async { + final source = await imageFromRgba( + Uint8List.fromList([ + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + ]), + 2, + 2, + ); + + final appliedCompleter = Completer(); + + sut = FillTool( + commandFactory: const CommandFactory(), + commandManager: commandManager, + graphicFactory: const GraphicFactory(), + type: ToolType.FILL, + getSourceImage: () async => source, + onFillApplied: () async { + if (!appliedCompleter.isCompleted) { + appliedCompleter.complete(); + } + }, + ); + + sut.onDown(const ui.Offset(0, 0), paint); + await appliedCompleter.future; + + expect(commandManager.undoStack.length, 1); + expect(commandManager.undoStack.first, isA()); + }); + + test('Filling outer region does not fill inner enclosed region', () async { + const width = 9; + const height = 9; + final rgba = Uint8List(width * height * 4); + + void setPixel(int x, int y, int r, int g, int b, int a) { + final index = rgbaIndex(x, y, width); + rgba[index] = r; + rgba[index + 1] = g; + rgba[index + 2] = b; + rgba[index + 3] = a; + } + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + setPixel(x, y, 255, 255, 255, 255); + } + } + + for (int x = 1; x <= 7; x++) { + setPixel(x, 1, 0, 0, 0, 255); + setPixel(x, 7, 0, 0, 0, 255); + } + for (int y = 1; y <= 7; y++) { + setPixel(1, y, 0, 0, 0, 255); + setPixel(7, y, 0, 0, 0, 255); + } + + for (int x = 3; x <= 5; x++) { + setPixel(x, 3, 0, 0, 0, 255); + setPixel(x, 5, 0, 0, 0, 255); + } + for (int y = 3; y <= 5; y++) { + setPixel(3, y, 0, 0, 0, 255); + setPixel(5, y, 0, 0, 0, 255); + } + + final source = await imageFromRgba(rgba, width, height); + final appliedCompleter = Completer(); + + sut = FillTool( + commandFactory: const CommandFactory(), + commandManager: commandManager, + graphicFactory: const GraphicFactory(), + type: ToolType.FILL, + getSourceImage: () async => source, + onFillApplied: () async { + if (!appliedCompleter.isCompleted) { + appliedCompleter.complete(); + } + }, + ); + + sut.onDown(const ui.Offset(2, 2), paint); + await appliedCompleter.future; + + expect(commandManager.undoStack.length, 1); + final fillCommand = commandManager.undoStack.first as FillCommand; + final outputImage = await imageFromPng(fillCommand.imageData); + final outputData = + await outputImage.toByteData(format: ui.ImageByteFormat.rawRgba); + expect(outputData, isNotNull); + + final outputPixels = outputData!.buffer.asUint8List(); + + final innerIndex = rgbaIndex(4, 4, width); + expect(outputPixels[innerIndex], 255); + expect(outputPixels[innerIndex + 1], 255); + expect(outputPixels[innerIndex + 2], 255); + expect(outputPixels[innerIndex + 3], 255); + + final annulusIndex = rgbaIndex(2, 2, width); + expect(outputPixels[annulusIndex], 0); + expect(outputPixels[annulusIndex + 1], 0); + expect(outputPixels[annulusIndex + 2], 0); + expect(outputPixels[annulusIndex + 3], 255); + }); +}