diff --git a/lib/pixels.dart b/lib/pixels.dart index 46e319b..ad763fa 100644 --- a/lib/pixels.dart +++ b/lib/pixels.dart @@ -1,7 +1,8 @@ library pixels; export 'src/editable_pixel_image.dart'; -export 'src/pixel_color_picker.dart'; +export 'src/pallete_color_picker.dart'; +export 'src/gradient_color_picker.dart'; export 'src/pixel_editor.dart'; export 'src/pixel_image.dart'; export 'src/pixel_palette.dart'; diff --git a/lib/src/editable_pixel_image.dart b/lib/src/editable_pixel_image.dart index 794081e..8509c29 100644 --- a/lib/src/editable_pixel_image.dart +++ b/lib/src/editable_pixel_image.dart @@ -44,25 +44,11 @@ class _EditablePixelImageState extends State { return AspectRatio( aspectRatio: widget.controller.width / widget.controller.height, child: LayoutBuilder(builder: (context, constraints) { + final tapHandler = makeTapHandler(constraints); + return GestureDetector( - onTapDown: (details) { - var xLocal = details.localPosition.dx; - var yLocal = details.localPosition.dy; - - var x = widget.controller.width * xLocal ~/ constraints.maxWidth; - var y = widget.controller.height * yLocal ~/ constraints.maxHeight; - - if (widget.onTappedPixel != null) { - widget.onTappedPixel!( - PixelTapDetails._( - x: x, - y: y, - index: y * widget.controller.width + x, - localPosition: details.localPosition, - ), - ); - } - }, + onTapDown: tapHandler, + onPanUpdate: tapHandler, child: PixelImage( width: widget.controller.value.width, height: widget.controller.value.height, @@ -73,6 +59,27 @@ class _EditablePixelImageState extends State { }), ); } + + void Function(dynamic) makeTapHandler(constraints) { + return (details) { + var xLocal = details.localPosition.dx; + var yLocal = details.localPosition.dy; + + var x = widget.controller.width * xLocal ~/ constraints.maxWidth; + var y = widget.controller.height * yLocal ~/ constraints.maxHeight; + + if (widget.onTappedPixel != null) { + widget.onTappedPixel!( + PixelTapDetails._( + x: x, + y: y, + index: y * widget.controller.width + x, + localPosition: details.localPosition, + ), + ); + } + }; + } } /// Provides details about a tapped pixel on an [EditablePixelImage]. @@ -99,13 +106,13 @@ class PixelTapDetails { class _PixelImageValue { final ByteData pixels; - final PixelPalette palette; + final PixelPalette? palette; final int width; final int height; const _PixelImageValue({ required this.pixels, - required this.palette, + this.palette, required this.width, required this.height, }); @@ -117,7 +124,7 @@ class PixelImageController extends ValueNotifier<_PixelImageValue> { late Uint8List _pixelBytes; /// The palette of the [EditablePixelImage] controlled by the controller. - final PixelPalette palette; + final PixelPalette? palette; /// Height in pixels of the [EditablePixelImage] controlled by the controller. final int height; @@ -132,22 +139,33 @@ class PixelImageController extends ValueNotifier<_PixelImageValue> { /// Creates a new [PixelImageController]. PixelImageController({ ByteData? pixels, - required this.palette, + this.palette, + Color? bgColor, required this.width, required this.height, this.onTappedPixel, }) : super(_PixelImageValue( - pixels: pixels ?? _emptyPixels(), + pixels: pixels ?? _emptyPixels(width, height, bgColor), palette: palette, width: width, height: height, )) { _pixelBytes = value.pixels.buffer.asUint8List(); - assert(_pixelBytes.length == width * height); + assert(_pixelBytes.length == area * 4); } - static ByteData _emptyPixels() { - var bytes = Uint8List(64 * 64); + static ByteData _emptyPixels(int width, int height, Color? fill) { + final area = width * height; + var bytes = Uint8List(area * 4); + + if (fill != null) { + for (int i = 0; i < area; i++) { + bytes[i * 4 + 0] = fill.red; + bytes[i * 4 + 1] = fill.green; + bytes[i * 4 + 2] = fill.blue; + bytes[i * 4 + 3] = fill.alpha; + } + } return bytes.buffer.asByteData(); } @@ -155,8 +173,11 @@ class PixelImageController extends ValueNotifier<_PixelImageValue> { /// controller. ByteData get pixels => _pixelBytes.buffer.asByteData(); + /// calculate the image's 2D area + int get area => width * height; + set pixels(ByteData pixels) { - assert(pixels.lengthInBytes == width * height); + assert(pixels.lengthInBytes == area * 4); _pixelBytes = pixels.buffer.asUint8List(); _update(); } @@ -164,13 +185,13 @@ class PixelImageController extends ValueNotifier<_PixelImageValue> { /// Sets a specific pixel in the [EditablePixelImage] controlled by the /// controller. void setPixel({ - required int colorIndex, + required Color color, required int x, required int y, }) { setPixelIndex( pixelIndex: y * width + x, - colorIndex: colorIndex, + color: color, ); _update(); } @@ -179,9 +200,12 @@ class PixelImageController extends ValueNotifier<_PixelImageValue> { /// controller. void setPixelIndex({ required pixelIndex, - required colorIndex, + required color, }) { - _pixelBytes[pixelIndex] = colorIndex; + _pixelBytes[pixelIndex * 4 + 0] = color.red; + _pixelBytes[pixelIndex * 4 + 1] = color.green; + _pixelBytes[pixelIndex * 4 + 2] = color.blue; + _pixelBytes[pixelIndex * 4 + 3] = color.alpha; _update(); } diff --git a/lib/src/gradient_color_picker.dart b/lib/src/gradient_color_picker.dart new file mode 100644 index 0000000..3c1592a --- /dev/null +++ b/lib/src/gradient_color_picker.dart @@ -0,0 +1,201 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// A gradient-equation based color picker. It can be displayed vertically or +/// horizontally depending on the [direction]. +class GradientColorPicker extends StatelessWidget { + /// Defines if the color picker should be displayed horizontally or + /// vertically. + final Axis direction; + + /// A callback for when the user picks a color. + final void Function(Color color) onSelected; + + /// A callback to calculate the color + final Color Function(double y) equation; + + /// The width or height (depending on it's direction) of the color picker. + final double crossAxisWidth; + + /// Dictates the resolution of the visualized gradient. More = smoother. + final int bands = 255; + + /// Changes the slider gizmo color + final Color sliderColor; + + /// Sets the gizmo starting position + final double sliderStartOffset; + + /// Creates a new [GradientColorPicker]. + const GradientColorPicker({ + required this.equation, + required this.onSelected, + this.direction = Axis.horizontal, + this.crossAxisWidth = 32.0, + this.sliderColor = Colors.white, + this.sliderStartOffset = 0.5, + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: direction == Axis.vertical ? crossAxisWidth : null, + height: direction == Axis.horizontal ? crossAxisWidth : null, + child: Flex( + direction: direction, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 1, + child: _GradientColorPickerWell( + equation: equation, + onTap: (x, y) => + onSelected(equation(direction == Axis.horizontal ? x : y)), + bands: bands, + sliderColor: sliderColor, + sliderStartOffset: sliderStartOffset, + direction: direction), + ) + ], + ), + ); + } +} + +class _GradientColorPickerWell extends StatefulWidget { + final Function(double x, double y) onTap; + final GlobalKey colorGradKey = GlobalKey(debugLabel: "colorGradient"); + final Color Function(double y) equation; + final int bands; + final Color sliderColor; + final double sliderStartOffset; + final Axis direction; + + _GradientColorPickerWell( + {required this.equation, + required this.onTap, + required this.bands, + required this.sliderColor, + required this.sliderStartOffset, + required this.direction}); + + Point pointFromTapDetails(details) { + final RenderBox renderBox = + colorGradKey.currentContext?.findRenderObject() as RenderBox; + final size = renderBox.size; + final double x = details.localPosition.dx / size.width; + final double y = details.localPosition.dy / size.height; + return Point(x, y); + } + + @override + State<_GradientColorPickerWell> createState() { + return _GradientColorPickerWellState(); + } +} + +class _GradientColorPickerWellState extends State<_GradientColorPickerWell> { + late double offset; + + @override + void initState() { + offset = widget.sliderStartOffset; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final List colors = []; + final List stops = List.filled(widget.bands, 0.0); + + for (int i = 0; i < widget.bands; i++) { + double alpha = i / widget.bands; + + if (widget.direction == Axis.horizontal) { + alpha = 1.0 - alpha; + } + + colors.add(widget.equation(alpha)); + stops[i] = i / widget.bands; + } + + return GestureDetector( + onTapDown: (details) { + final p = widget.pointFromTapDetails(details); + final double x = p.x.toDouble(); + final double y = p.y.toDouble(); + widget.onTap(x, y); + setState(() { + offset = widget.direction == Axis.horizontal ? x : y; + }); + }, + child: Stack(children: [ + makeSliderGizmo(), + Container( + key: widget.colorGradKey, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + stops: stops, + colors: colors, + )), + ), + makeSliderGizmo(), + ])); + } + + Widget makeSliderGizmo() => CustomPaint( + size: Size.infinite, + painter: GradientSliderPainter( + offset, widget.direction, widget.bands, widget.sliderColor), + willChange: true, + ); +} + +/// Custom paints the slider gizmo +class GradientSliderPainter extends CustomPainter { + /// The offset along the align axis + double offset; + + /// The align axis + Axis direction; + + /// Helps calculate where the slider should snap to + int bands; + + /// The stroke color of the slider + Color borderColor; + + /// Use the axis [offset] to position the gizmo + GradientSliderPainter( + this.offset, this.direction, this.bands, this.borderColor) + : super(); + + /// Paints a white rectangle representing the slider gizmo + @override + void paint(Canvas canvas, Size size) { + Offset center = Offset(size.width / 2.0, offset * size.height); + double width = size.width; + double height = size.height / bands; + + if (direction == Axis.horizontal) { + center = Offset(offset * size.width, size.height / 2.0); + width = size.width / bands; + height = size.height; + } + + canvas.drawRect( + Rect.fromCenter(center: center, width: width, height: height), + Paint() + ..color = borderColor + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke); + } + + /// Never repaint unless changed + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; +} diff --git a/lib/src/pixel_color_picker.dart b/lib/src/pallete_color_picker.dart similarity index 90% rename from lib/src/pixel_color_picker.dart rename to lib/src/pallete_color_picker.dart index dd0b1da..fc4aea5 100644 --- a/lib/src/pixel_color_picker.dart +++ b/lib/src/pallete_color_picker.dart @@ -3,7 +3,7 @@ import 'package:pixels/src/pixel_palette.dart'; /// A [PixelPalette] color picker. It can be displayed vertically or /// horizontally depending on the [direction]. -class PixelColorPicker extends StatelessWidget { +class PaletteColorPicker extends StatelessWidget { /// The palette used by the color picker. final PixelPalette palette; @@ -20,8 +20,8 @@ class PixelColorPicker extends StatelessWidget { /// The width or height (depending on it's direction) of the color picker. final double crossAxisWidth; - /// Creates a new [PixelColorPicker]. - const PixelColorPicker({ + /// Creates a new [PaletteColorPicker]. + const PaletteColorPicker({ required this.selectedIndex, required this.onChanged, required this.palette, @@ -42,7 +42,7 @@ class PixelColorPicker extends StatelessWidget { for (var i = 0; i < palette.colors.length; i++) Expanded( flex: 1, - child: _PixelColorPickerWell( + child: _PalleteColorPickerWell( index: i, palette: palette, selected: selectedIndex == i, @@ -57,13 +57,13 @@ class PixelColorPicker extends StatelessWidget { } } -class _PixelColorPickerWell extends StatelessWidget { +class _PalleteColorPickerWell extends StatelessWidget { final int index; final PixelPalette palette; final bool selected; final VoidCallback onTap; - const _PixelColorPickerWell({ + const _PalleteColorPickerWell({ required this.index, required this.palette, required this.selected, diff --git a/lib/src/pixel_editor.dart b/lib/src/pixel_editor.dart index f628f12..23c0117 100644 --- a/lib/src/pixel_editor.dart +++ b/lib/src/pixel_editor.dart @@ -1,6 +1,10 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:pixels/src/editable_pixel_image.dart'; -import 'package:pixels/src/pixel_color_picker.dart'; +import 'package:pixels/src/pallete_color_picker.dart'; +import 'package:pixels/src/gradient_color_picker.dart'; /// A pixel editor widget where the colors and dimensions are specified in the /// [controller]. Whenver a pixel is set, [onSetPixel] is called. The pixel @@ -25,10 +29,38 @@ class PixelEditor extends StatefulWidget { } class _PixelEditorState extends State { - int _selectedColor = 0; + int _selectedColorIndex = 0; + late Color _selectedColor; + late Color _finalColor; + double _selectedLuminosity = 0.5; + + /// Mixes [colorIn] by luminosity `L` + /// L < 0.5 will darken, S > 0.5 will brighten + Color luminate(Color colorIn) { + final double r = colorIn.red.toDouble(); + final double g = colorIn.green.toDouble(); + final double b = colorIn.blue.toDouble(); + final double L = 255 * (_selectedLuminosity * 2 - 1); + final ri = clampDouble(r + L, 0, 255).toInt(); + final gi = clampDouble(g + L, 0, 255).toInt(); + final bi = clampDouble(b + L, 0, 255).toInt(); + + return Color.fromARGB(colorIn.alpha, ri, gi, bi); + } + + /// Given some delta [a] 0.0-1.0, sample a color from the primary color spectrum + Color sampleRainbowColor(double a) { + const pi2 = pi * 2.0; + double r = ((sin(a * pi2 + 2) + 1) / 2) * 255; + double g = ((sin(a * pi2 + 0) + 1) / 2) * 255; + double b = ((sin(a * pi2 + 4) + 1) / 2) * 255; + return Color.fromARGB(255, r.floor(), g.floor(), b.floor()); + } @override void initState() { + _selectedColor = _finalColor = sampleRainbowColor(1.0); + super.initState(); } @@ -38,41 +70,116 @@ class _PixelEditorState extends State { var isHorizontal = constraints.maxWidth > constraints.maxHeight; return Flex( - direction: isHorizontal ? Axis.horizontal : Axis.vertical, - mainAxisSize: MainAxisSize.min, - children: [ - EditablePixelImage( - controller: widget.controller, - onTappedPixel: (details) { - widget.controller.setPixel( - colorIndex: _selectedColor, - x: details.x, - y: details.y, - ); - if (widget.onSetPixel != null) { - widget.onSetPixel!( - SetPixelDetails._( - tapDetails: details, - colorIndex: _selectedColor, - ), - ); - } - }, - ), - PixelColorPicker( - direction: isHorizontal ? Axis.vertical : Axis.horizontal, - palette: widget.controller.palette, - selectedIndex: _selectedColor, - onChanged: (index) { - setState(() { - _selectedColor = index; - }); - }, - ), - ], - ); + direction: isHorizontal ? Axis.horizontal : Axis.vertical, + mainAxisSize: MainAxisSize.min, + children: [ + EditablePixelImage( + controller: widget.controller, + onTappedPixel: handleTappedPixel, + ), + if (widget.controller.palette != null) ...[ + makePaletteColorPicker(isHorizontal), + ] else ...[ + makeLumenGradientPicker(isHorizontal), + makeRainbowGradientPicker(isHorizontal), + ], + ]); }); } + +// uses palette picker tool for color selection + Widget makePaletteColorPicker(bool isHorizontal) { + return PaletteColorPicker( + direction: isHorizontal ? Axis.vertical : Axis.horizontal, + palette: widget.controller.palette!, + selectedIndex: _selectedColorIndex, + onChanged: (index) { + setState(() { + _selectedColorIndex = index; + _finalColor = _selectedColor = + widget.controller.palette!.colors[_selectedColorIndex]; + }); + }, + ); + } + + // uses a linear equation to cycle through the rainbow + Widget makeRainbowGradientPicker(bool isHorizontal) { + return GradientColorPicker( + equation: sampleRainbowColor, + onSelected: (color) { + setState(() { + _finalColor = luminate(_selectedColor = color); + }); + }, + direction: isHorizontal ? Axis.vertical : Axis.horizontal, + sliderStartOffset: 1.0, + ); + } + +// uses a linear equation from black to white + Widget makeLumenGradientPicker(bool isHorizontal) { + return GradientColorPicker( + equation: (y) { + int r = (y * 255).floor(); + return Color.fromARGB(255, r, r, r); + }, + onSelected: (color) { + setState(() { + // convert to [0.0, 1.0] range for 0-100% intensity + _selectedLuminosity = color.red / 255; + // mix + _finalColor = luminate(_selectedColor); + }); + }, + direction: isHorizontal ? Axis.vertical : Axis.horizontal, + sliderColor: Colors.yellow, + sliderStartOffset: _selectedLuminosity, + ); + } + + void handleTappedPixel(details) { + if (widget.controller.palette == null) { + handleColorTap(details); + return; + } + + handlePaletteColorTap(details); + } + + void handleColorTap(details) { + widget.controller.setPixel( + color: _finalColor, + x: details.x, + y: details.y, + ); + if (widget.onSetPixel != null) { + widget.onSetPixel!( + SetPixelDetails._( + tapDetails: details, + colorValue: _finalColor, + ), + ); + } + } + + void handlePaletteColorTap(details) { + final Color color = widget.controller.palette!.colors[_selectedColorIndex]; + widget.controller.setPixel( + color: color, + x: details.x, + y: details.y, + ); + if (widget.onSetPixel != null) { + widget.onSetPixel!( + SetPixelDetails._( + tapDetails: details, + colorIndex: _selectedColorIndex, + colorValue: color, + ), + ); + } + } } /// Details of a newly set pixel. @@ -80,8 +187,12 @@ class SetPixelDetails { /// Information about where the pixel is located. final PixelTapDetails tapDetails; - /// The newly set color index of the pixel. - final int colorIndex; + /// The newly set palette color index of the pixel. + final int? colorIndex; + + /// The newly set color value of the pixel. + final Color colorValue; - SetPixelDetails._({required this.tapDetails, required this.colorIndex}); + SetPixelDetails._( + {required this.tapDetails, this.colorIndex, required this.colorValue}); } diff --git a/lib/src/pixel_image.dart b/lib/src/pixel_image.dart index 471654e..ed669a7 100644 --- a/lib/src/pixel_image.dart +++ b/lib/src/pixel_image.dart @@ -14,7 +14,7 @@ class PixelImage extends StatefulWidget { final int height; /// The palette used by this image. - final PixelPalette palette; + final PixelPalette? palette; /// The [ByteData] representing the pixels in the image. Each byte corresponds /// to one pixel. @@ -24,13 +24,16 @@ class PixelImage extends StatefulWidget { const PixelImage({ required this.width, required this.height, - required this.palette, + this.palette, required this.pixels, super.key, }); @override State createState() => _PixelImageState(); + + /// calculate the image's 2D area + int get area => width * height; } class _PixelImageState extends State { @@ -43,27 +46,10 @@ class _PixelImageState extends State { } Future _updateUIImage() async { - assert(widget.pixels.lengthInBytes == widget.width * widget.height); - - var dstImageBytes = Uint8List(widget.width * widget.height * 4); - - var srcPixels = widget.pixels.buffer.asUint8List(); - - // Iterate over all pixels. - for (var i = 0; i < widget.width * widget.height; i++) { - var color = widget.palette.colors[srcPixels[i]]; - var r = color.red; - var g = color.green; - var b = color.blue; - var a = color.alpha; - - dstImageBytes[i * 4 + 0] = r; - dstImageBytes[i * 4 + 1] = g; - dstImageBytes[i * 4 + 2] = b; - dstImageBytes[i * 4 + 3] = a; - } + assert(widget.pixels.lengthInBytes == widget.area * 4); - var immutableBuffer = await ui.ImmutableBuffer.fromUint8List(dstImageBytes); + var immutableBuffer = await ui.ImmutableBuffer.fromUint8List( + widget.pixels.buffer.asUint8List()); var imageDescriptor = ui.ImageDescriptor.raw( immutableBuffer, width: widget.width,