diff --git a/Minint.Core/Services/Impl/DrawingService.cs b/Minint.Core/Services/Impl/DrawingService.cs
new file mode 100644
index 0000000..dafbc88
--- /dev/null
+++ b/Minint.Core/Services/Impl/DrawingService.cs
@@ -0,0 +1,43 @@
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class DrawingService : IDrawingService
+{
+ public void ApplyBrush(MinintLayer layer, int cx, int cy, int radius, int colorIndex, int width, int height)
+ {
+ foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
+ layer.Pixels[y * width + x] = colorIndex;
+ }
+
+ public void ApplyEraser(MinintLayer layer, int cx, int cy, int radius, int width, int height)
+ {
+ foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
+ layer.Pixels[y * width + x] = 0;
+ }
+
+ public List<(int X, int Y)> GetBrushMask(int cx, int cy, int radius, int width, int height)
+ {
+ var mask = new List<(int, int)>();
+ int r = Math.Max(radius, 0);
+ int r2 = r * r;
+
+ int xMin = Math.Max(0, cx - r);
+ int xMax = Math.Min(width - 1, cx + r);
+ int yMin = Math.Max(0, cy - r);
+ int yMax = Math.Min(height - 1, cy + r);
+
+ for (int py = yMin; py <= yMax; py++)
+ {
+ int dy = py - cy;
+ for (int px = xMin; px <= xMax; px++)
+ {
+ int dx = px - cx;
+ if (dx * dx + dy * dy <= r2)
+ mask.Add((px, py));
+ }
+ }
+
+ return mask;
+ }
+}
diff --git a/Minint.Core/Services/Impl/FloodFillService.cs b/Minint.Core/Services/Impl/FloodFillService.cs
new file mode 100644
index 0000000..986b83f
--- /dev/null
+++ b/Minint.Core/Services/Impl/FloodFillService.cs
@@ -0,0 +1,47 @@
+using Minint.Core.Models;
+
+namespace Minint.Core.Services.Impl;
+
+public sealed class FloodFillService : IFloodFillService
+{
+ public void Fill(MinintLayer layer, int x, int y, int newColorIndex, int width, int height)
+ {
+ if (x < 0 || x >= width || y < 0 || y >= height)
+ return;
+
+ var pixels = layer.Pixels;
+ int targetIndex = pixels[y * width + x];
+
+ if (targetIndex == newColorIndex)
+ return;
+
+ var queue = new Queue<(int X, int Y)>();
+ var visited = new bool[width * height];
+
+ queue.Enqueue((x, y));
+ visited[y * width + x] = true;
+
+ while (queue.Count > 0)
+ {
+ var (cx, cy) = queue.Dequeue();
+ pixels[cy * width + cx] = newColorIndex;
+
+ Span<(int, int)> neighbors =
+ [
+ (cx - 1, cy), (cx + 1, cy),
+ (cx, cy - 1), (cx, cy + 1)
+ ];
+
+ foreach (var (nx, ny) in neighbors)
+ {
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height)
+ continue;
+ int ni = ny * width + nx;
+ if (visited[ni] || pixels[ni] != targetIndex)
+ continue;
+ visited[ni] = true;
+ queue.Enqueue((nx, ny));
+ }
+ }
+ }
+}
diff --git a/Minint/App.axaml b/Minint/App.axaml
index 7ee4983..9bc2aa1 100644
--- a/Minint/App.axaml
+++ b/Minint/App.axaml
@@ -11,5 +11,6 @@
+
\ No newline at end of file
diff --git a/Minint/Controls/PixelCanvas.cs b/Minint/Controls/PixelCanvas.cs
index c60ed6c..9023710 100644
--- a/Minint/Controls/PixelCanvas.cs
+++ b/Minint/Controls/PixelCanvas.cs
@@ -1,325 +1,405 @@
-using System;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
-using Avalonia.Input;
-using Avalonia.Media;
-using Avalonia.Media.Imaging;
-using Avalonia.Threading;
-
-namespace Minint.Controls;
-
-///
-/// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom).
-/// Supports nearest-neighbor scaling, pixel grid overlay, scrollbars, and mouse interaction.
-/// Input model:
-///
-/// - Ctrl+Wheel: zoom at cursor
-/// - Wheel: scroll vertically
-/// - Shift+Wheel: scroll horizontally
-/// - Touchpad two-finger scroll: free pan (Delta.X + Delta.Y)
-/// - Middle mouse drag: pan
-///
-///
-public class PixelCanvas : Control
-{
- #region Styled Properties
-
- public static readonly StyledProperty SourceBitmapProperty =
- AvaloniaProperty.Register(nameof(SourceBitmap));
-
- public static readonly StyledProperty ShowGridProperty =
- AvaloniaProperty.Register(nameof(ShowGrid), defaultValue: false);
-
- public WriteableBitmap? SourceBitmap
- {
- get => GetValue(SourceBitmapProperty);
- set => SetValue(SourceBitmapProperty, value);
- }
-
- public bool ShowGrid
- {
- get => GetValue(ShowGridProperty);
- set => SetValue(ShowGridProperty, value);
- }
-
- #endregion
-
- private readonly Viewport _viewport = new();
- private bool _isPanning;
- private Point _panStart;
- private double _panStartOffsetX, _panStartOffsetY;
- private bool _viewportInitialized;
-
- private ScrollBar? _hScrollBar;
- private ScrollBar? _vScrollBar;
- private bool _suppressScrollSync;
-
- private const double ScrollPixelsPerTick = 20.0;
-
- public Viewport Viewport => _viewport;
-
- static PixelCanvas()
- {
- AffectsRender(SourceBitmapProperty, ShowGridProperty);
- FocusableProperty.OverrideDefaultValue(true);
- }
-
- public PixelCanvas()
- {
- ClipToBounds = true;
- }
-
- ///
- /// Connects external ScrollBar controls. Call once after the UI is built.
- ///
- public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
- {
- if (_hScrollBar is not null)
- _hScrollBar.ValueChanged -= OnHScrollChanged;
- if (_vScrollBar is not null)
- _vScrollBar.ValueChanged -= OnVScrollChanged;
-
- _hScrollBar = horizontal;
- _vScrollBar = vertical;
-
- _hScrollBar.ValueChanged += OnHScrollChanged;
- _vScrollBar.ValueChanged += OnVScrollChanged;
- }
-
- #region Rendering
-
- public override void Render(DrawingContext context)
- {
- base.Render(context);
- context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
-
- var bmp = SourceBitmap;
- if (bmp is null)
- return;
-
- int imgW = bmp.PixelSize.Width;
- int imgH = bmp.PixelSize.Height;
-
- if (!_viewportInitialized)
- {
- _viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
- _viewportInitialized = true;
- }
-
- DrawCheckerboard(context, imgW, imgH);
-
- var destRect = _viewport.ImageScreenRect(imgW, imgH);
- var srcRect = new Rect(0, 0, imgW, imgH);
- RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
- context.DrawImage(bmp, srcRect, destRect);
-
- if (ShowGrid && _viewport.Zoom >= 4)
- DrawPixelGrid(context, imgW, imgH);
-
- // Defer scrollbar sync — updating layout properties during Render is forbidden
- int w = imgW, h = imgH;
- Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
- }
-
- private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
- {
- var rect = _viewport.ImageScreenRect(imgW, imgH);
- var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
- var visible = rect.Intersect(clip);
- if (visible.Width <= 0 || visible.Height <= 0) return;
-
- const int checkerSize = 8;
- var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
- var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
-
- using (context.PushClip(visible))
- {
- context.FillRectangle(light, visible);
-
- double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
- double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
-
- for (double y = startY; y < visible.Bottom; y += checkerSize)
- {
- for (double x = startX; x < visible.Right; x += checkerSize)
- {
- int col = (int)((x - rect.X) / checkerSize);
- int row = (int)((y - rect.Y) / checkerSize);
- if ((col + row) % 2 == 1)
- context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize));
- }
- }
- }
- }
-
- private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
- {
- var pen = new Pen(new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)), 1);
- var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
- var imgRect = _viewport.ImageScreenRect(imgW, imgH);
- var visible = imgRect.Intersect(clip);
- if (visible.Width <= 0 || visible.Height <= 0) return;
-
- var (startPx, startPy) = _viewport.ScreenToPixel(visible.X, visible.Y);
- var (endPx, endPy) = _viewport.ScreenToPixel(visible.Right, visible.Bottom);
- startPx = Math.Max(0, startPx);
- startPy = Math.Max(0, startPy);
- endPx = Math.Min(imgW, endPx + 1);
- endPy = Math.Min(imgH, endPy + 1);
-
- using (context.PushClip(visible))
- {
- for (int px = startPx; px <= endPx; px++)
- {
- var (sx, _) = _viewport.PixelToScreen(px, 0);
- context.DrawLine(pen, new Point(sx, visible.Top), new Point(sx, visible.Bottom));
- }
-
- for (int py = startPy; py <= endPy; py++)
- {
- var (_, sy) = _viewport.PixelToScreen(0, py);
- context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
- }
- }
- }
-
- #endregion
-
- #region Scrollbar Sync
-
- private void SyncScrollBars(int imgW, int imgH)
- {
- if (_hScrollBar is null || _vScrollBar is null) return;
-
- _suppressScrollSync = true;
-
- // Scrollbar value is negated offset: increasing value = scroll right = offset decreases
- var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
- _hScrollBar.Minimum = -hMax;
- _hScrollBar.Maximum = -hMin;
- _hScrollBar.Value = -hVal;
- _hScrollBar.ViewportSize = hView;
-
- var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
- _vScrollBar.Minimum = -vMax;
- _vScrollBar.Maximum = -vMin;
- _vScrollBar.Value = -vVal;
- _vScrollBar.ViewportSize = vView;
-
- _suppressScrollSync = false;
- }
-
- private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
- {
- if (_suppressScrollSync) return;
- var (imgW, imgH) = GetImageSize();
- _viewport.SetOffset(-e.NewValue, _viewport.OffsetY,
- imgW, imgH, Bounds.Width, Bounds.Height);
- InvalidateVisual();
- }
-
- private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
- {
- if (_suppressScrollSync) return;
- var (imgW, imgH) = GetImageSize();
- _viewport.SetOffset(_viewport.OffsetX, -e.NewValue,
- imgW, imgH, Bounds.Width, Bounds.Height);
- InvalidateVisual();
- }
-
- #endregion
-
- #region Mouse Input
-
- private (int W, int H) GetImageSize()
- {
- var bmp = SourceBitmap;
- return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
- }
-
- protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
- {
- base.OnPointerWheelChanged(e);
- var (imgW, imgH) = GetImageSize();
- if (imgW == 0) return;
-
- bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
- bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
-
- if (ctrl)
- {
- var pos = e.GetPosition(this);
- _viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y,
- imgW, imgH, Bounds.Width, Bounds.Height);
- }
- else
- {
- double dx = e.Delta.X * ScrollPixelsPerTick;
- double dy = e.Delta.Y * ScrollPixelsPerTick;
-
- if (shift && Math.Abs(e.Delta.X) < 0.001)
- {
- dx = dy;
- dy = 0;
- }
-
- _viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
- }
-
- InvalidateVisual();
- e.Handled = true;
- }
-
- protected override void OnPointerPressed(PointerPressedEventArgs e)
- {
- base.OnPointerPressed(e);
-
- if (e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed)
- {
- _isPanning = true;
- _panStart = e.GetPosition(this);
- _panStartOffsetX = _viewport.OffsetX;
- _panStartOffsetY = _viewport.OffsetY;
- e.Handled = true;
- }
- }
-
- protected override void OnPointerMoved(PointerEventArgs e)
- {
- base.OnPointerMoved(e);
-
- if (_isPanning)
- {
- var pos = e.GetPosition(this);
- var (imgW, imgH) = GetImageSize();
- _viewport.SetOffset(
- _panStartOffsetX + (pos.X - _panStart.X),
- _panStartOffsetY + (pos.Y - _panStart.Y),
- imgW, imgH, Bounds.Width, Bounds.Height);
- InvalidateVisual();
- e.Handled = true;
- }
- }
-
- protected override void OnPointerReleased(PointerReleasedEventArgs e)
- {
- base.OnPointerReleased(e);
-
- if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle)
- {
- _isPanning = false;
- e.Handled = true;
- }
- }
-
- #endregion
-
- protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
- {
- base.OnPropertyChanged(change);
-
- if (change.Property == SourceBitmapProperty)
- _viewportInitialized = false;
- }
-}
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+
+namespace Minint.Controls;
+
+public class PixelCanvas : Control
+{
+ #region Styled Properties
+
+ public static readonly StyledProperty SourceBitmapProperty =
+ AvaloniaProperty.Register(nameof(SourceBitmap));
+
+ public static readonly StyledProperty ShowGridProperty =
+ AvaloniaProperty.Register(nameof(ShowGrid), defaultValue: false);
+
+ public WriteableBitmap? SourceBitmap
+ {
+ get => GetValue(SourceBitmapProperty);
+ set => SetValue(SourceBitmapProperty, value);
+ }
+
+ public bool ShowGrid
+ {
+ get => GetValue(ShowGridProperty);
+ set => SetValue(ShowGridProperty, value);
+ }
+
+ #endregion
+
+ #region Events for tool interaction
+
+ /// Fires when the user presses the left mouse button at an image pixel.
+ public event Action? ToolDown;
+
+ /// Fires when the user drags with left button held at an image pixel.
+ public event Action? ToolDrag;
+
+ /// Fires when the cursor moves over the image (pixel coords, or null if outside).
+ public event Action<(int X, int Y)?>? CursorPixelChanged;
+
+ /// Set by the host to provide preview mask pixels for overlay.
+ public Func?>? GetPreviewMask { get; set; }
+
+ #endregion
+
+ private readonly Viewport _viewport = new();
+ private bool _isPanning;
+ private bool _isDrawing;
+ private Point _panStart;
+ private double _panStartOffsetX, _panStartOffsetY;
+ private bool _viewportInitialized;
+ private (int X, int Y)? _lastCursorPixel;
+
+ private ScrollBar? _hScrollBar;
+ private ScrollBar? _vScrollBar;
+ private bool _suppressScrollSync;
+
+ private const double ScrollPixelsPerTick = 20.0;
+
+ public Viewport Viewport => _viewport;
+
+ static PixelCanvas()
+ {
+ AffectsRender(SourceBitmapProperty, ShowGridProperty);
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ public PixelCanvas()
+ {
+ ClipToBounds = true;
+ }
+
+ public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
+ {
+ if (_hScrollBar is not null) _hScrollBar.ValueChanged -= OnHScrollChanged;
+ if (_vScrollBar is not null) _vScrollBar.ValueChanged -= OnVScrollChanged;
+ _hScrollBar = horizontal;
+ _vScrollBar = vertical;
+ _hScrollBar.ValueChanged += OnHScrollChanged;
+ _vScrollBar.ValueChanged += OnVScrollChanged;
+ }
+
+ #region Rendering
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+ context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
+
+ var bmp = SourceBitmap;
+ if (bmp is null) return;
+
+ int imgW = bmp.PixelSize.Width;
+ int imgH = bmp.PixelSize.Height;
+
+ if (!_viewportInitialized)
+ {
+ _viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
+ _viewportInitialized = true;
+ }
+
+ DrawCheckerboard(context, imgW, imgH);
+
+ var destRect = _viewport.ImageScreenRect(imgW, imgH);
+ var srcRect = new Rect(0, 0, imgW, imgH);
+ RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
+ context.DrawImage(bmp, srcRect, destRect);
+
+ if (ShowGrid && _viewport.Zoom >= 4)
+ DrawPixelGrid(context, imgW, imgH);
+
+ DrawToolPreview(context, imgW, imgH);
+
+ int w = imgW, h = imgH;
+ Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
+ }
+
+ private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
+ {
+ var rect = _viewport.ImageScreenRect(imgW, imgH);
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ var visible = rect.Intersect(clip);
+ if (visible.Width <= 0 || visible.Height <= 0) return;
+
+ const int checkerSize = 8;
+ var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
+ var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
+
+ using (context.PushClip(visible))
+ {
+ context.FillRectangle(light, visible);
+ double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
+ double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
+ for (double y = startY; y < visible.Bottom; y += checkerSize)
+ {
+ for (double x = startX; x < visible.Right; x += checkerSize)
+ {
+ int col = (int)((x - rect.X) / checkerSize);
+ int row = (int)((y - rect.Y) / checkerSize);
+ if ((col + row) % 2 == 1)
+ context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize));
+ }
+ }
+ }
+ }
+
+ private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
+ {
+ var pen = new Pen(new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)), 1);
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ var imgRect = _viewport.ImageScreenRect(imgW, imgH);
+ var visible = imgRect.Intersect(clip);
+ if (visible.Width <= 0 || visible.Height <= 0) return;
+
+ var (startPx, startPy) = _viewport.ScreenToPixel(visible.X, visible.Y);
+ var (endPx, endPy) = _viewport.ScreenToPixel(visible.Right, visible.Bottom);
+ startPx = Math.Max(0, startPx);
+ startPy = Math.Max(0, startPy);
+ endPx = Math.Min(imgW, endPx + 1);
+ endPy = Math.Min(imgH, endPy + 1);
+
+ using (context.PushClip(visible))
+ {
+ for (int px = startPx; px <= endPx; px++)
+ {
+ var (sx, _) = _viewport.PixelToScreen(px, 0);
+ context.DrawLine(pen, new Point(sx, visible.Top), new Point(sx, visible.Bottom));
+ }
+ for (int py = startPy; py <= endPy; py++)
+ {
+ var (_, sy) = _viewport.PixelToScreen(0, py);
+ context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
+ }
+ }
+ }
+
+ private void DrawToolPreview(DrawingContext context, int imgW, int imgH)
+ {
+ var mask = GetPreviewMask?.Invoke();
+ if (mask is null || mask.Count == 0) return;
+
+ double zoom = _viewport.Zoom;
+ var previewBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255));
+ var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), 1);
+
+ var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
+ using (context.PushClip(clip))
+ {
+ foreach (var (px, py) in mask)
+ {
+ var (sx, sy) = _viewport.PixelToScreen(px, py);
+ var r = new Rect(sx, sy, zoom, zoom);
+ context.FillRectangle(previewBrush, r);
+ }
+
+ // Outline around the mask bounding box
+ if (mask.Count > 0)
+ {
+ int minX = mask[0].X, maxX = mask[0].X;
+ int minY = mask[0].Y, maxY = mask[0].Y;
+ foreach (var (px, py) in mask)
+ {
+ if (px < minX) minX = px;
+ if (px > maxX) maxX = px;
+ if (py < minY) minY = py;
+ if (py > maxY) maxY = py;
+ }
+ var (ox, oy) = _viewport.PixelToScreen(minX, minY);
+ var outlineRect = new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom);
+ context.DrawRectangle(outlinePen, outlineRect);
+ }
+ }
+ }
+
+ #endregion
+
+ #region Scrollbar Sync
+
+ private void SyncScrollBars(int imgW, int imgH)
+ {
+ if (_hScrollBar is null || _vScrollBar is null) return;
+ _suppressScrollSync = true;
+
+ var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
+ _hScrollBar.Minimum = -hMax;
+ _hScrollBar.Maximum = -hMin;
+ _hScrollBar.Value = -hVal;
+ _hScrollBar.ViewportSize = hView;
+
+ var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
+ _vScrollBar.Minimum = -vMax;
+ _vScrollBar.Maximum = -vMin;
+ _vScrollBar.Value = -vVal;
+ _vScrollBar.ViewportSize = vView;
+
+ _suppressScrollSync = false;
+ }
+
+ private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (_suppressScrollSync) return;
+ var (imgW, imgH) = GetImageSize();
+ _viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height);
+ InvalidateVisual();
+ }
+
+ private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (_suppressScrollSync) return;
+ var (imgW, imgH) = GetImageSize();
+ _viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height);
+ InvalidateVisual();
+ }
+
+ #endregion
+
+ #region Mouse Input
+
+ private (int W, int H) GetImageSize()
+ {
+ var bmp = SourceBitmap;
+ return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
+ }
+
+ private (int X, int Y)? ScreenToPixelClamped(Point pos)
+ {
+ var (imgW, imgH) = GetImageSize();
+ if (imgW == 0) return null;
+ var (px, py) = _viewport.ScreenToPixel(pos.X, pos.Y);
+ if (px < 0 || px >= imgW || py < 0 || py >= imgH)
+ return null;
+ return (px, py);
+ }
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ var (imgW, imgH) = GetImageSize();
+ if (imgW == 0) return;
+
+ bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
+ bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
+
+ if (ctrl)
+ {
+ var pos = e.GetPosition(this);
+ _viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, imgW, imgH, Bounds.Width, Bounds.Height);
+ }
+ else
+ {
+ double dx = e.Delta.X * ScrollPixelsPerTick;
+ double dy = e.Delta.Y * ScrollPixelsPerTick;
+ if (shift && Math.Abs(e.Delta.X) < 0.001)
+ {
+ dx = dy;
+ dy = 0;
+ }
+ _viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
+ }
+
+ InvalidateVisual();
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ var props = e.GetCurrentPoint(this).Properties;
+
+ if (props.IsMiddleButtonPressed)
+ {
+ _isPanning = true;
+ _panStart = e.GetPosition(this);
+ _panStartOffsetX = _viewport.OffsetX;
+ _panStartOffsetY = _viewport.OffsetY;
+ e.Handled = true;
+ }
+ else if (props.IsLeftButtonPressed && !_isPanning)
+ {
+ var pixel = ScreenToPixelClamped(e.GetPosition(this));
+ if (pixel is not null)
+ {
+ _isDrawing = true;
+ ToolDown?.Invoke(pixel.Value.X, pixel.Value.Y);
+ e.Handled = true;
+ }
+ }
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ var pos = e.GetPosition(this);
+
+ if (_isPanning)
+ {
+ var (imgW, imgH) = GetImageSize();
+ _viewport.SetOffset(
+ _panStartOffsetX + (pos.X - _panStart.X),
+ _panStartOffsetY + (pos.Y - _panStart.Y),
+ imgW, imgH, Bounds.Width, Bounds.Height);
+ InvalidateVisual();
+ e.Handled = true;
+ return;
+ }
+
+ // Update preview cursor position
+ var pixel = ScreenToPixelClamped(pos);
+ if (pixel != _lastCursorPixel)
+ {
+ _lastCursorPixel = pixel;
+ CursorPixelChanged?.Invoke(pixel);
+ InvalidateVisual();
+ }
+
+ if (_isDrawing && pixel is not null)
+ {
+ ToolDrag?.Invoke(pixel.Value.X, pixel.Value.Y);
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+
+ if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle)
+ {
+ _isPanning = false;
+ e.Handled = true;
+ }
+ else if (_isDrawing && e.InitialPressMouseButton == MouseButton.Left)
+ {
+ _isDrawing = false;
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ if (_lastCursorPixel is not null)
+ {
+ _lastCursorPixel = null;
+ CursorPixelChanged?.Invoke(null);
+ InvalidateVisual();
+ }
+ }
+
+ #endregion
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == SourceBitmapProperty)
+ _viewportInitialized = false;
+ }
+}
diff --git a/Minint/Minint.csproj b/Minint/Minint.csproj
index 9a8fa38..218c12a 100644
--- a/Minint/Minint.csproj
+++ b/Minint/Minint.csproj
@@ -1,32 +1,33 @@
-
-
- WinExe
- net10.0
- enable
- true
- true
- app.manifest
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- None
- All
-
-
-
-
+
+
+ WinExe
+ net10.0
+ enable
+ true
+ true
+ app.manifest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs
index 100306f..5ea0e4f 100644
--- a/Minint/ViewModels/EditorViewModel.cs
+++ b/Minint/ViewModels/EditorViewModel.cs
@@ -1,163 +1,270 @@
-using System;
-using System.Collections.ObjectModel;
-using Avalonia;
-using Avalonia.Media.Imaging;
-using CommunityToolkit.Mvvm.ComponentModel;
-using Minint.Core.Models;
-using Minint.Core.Services;
-using Minint.Core.Services.Impl;
-
-namespace Minint.ViewModels;
-
-public partial class EditorViewModel : ViewModelBase
-{
- private readonly ICompositor _compositor = new Compositor();
- private readonly IPaletteService _paletteService = new PaletteService();
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(HasContainer))]
- [NotifyPropertyChangedFor(nameof(Title))]
- private MinintContainer? _container;
-
- [ObservableProperty]
- private MinintDocument? _activeDocument;
-
- [ObservableProperty]
- private MinintLayer? _activeLayer;
-
- private bool _suppressDocumentSync;
-
- [ObservableProperty]
- private WriteableBitmap? _canvasBitmap;
-
- [ObservableProperty]
- private bool _showGrid;
-
- ///
- /// Path of the currently open file, or null for unsaved new containers.
- ///
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(Title))]
- private string? _filePath;
-
- public bool HasContainer => Container is not null;
-
- public string Title => FilePath is not null
- ? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
- : Container is not null
- ? "Minint — Untitled"
- : "Minint";
-
- public ObservableCollection Documents { get; } = [];
- public ObservableCollection Layers { get; } = [];
-
- public void NewContainer(int width, int height)
- {
- var c = new MinintContainer(width, height);
- c.AddNewDocument("Document 1");
- LoadContainer(c, null);
- }
-
- public void LoadContainer(MinintContainer container, string? path)
- {
- Container = container;
- FilePath = path;
-
- Documents.Clear();
- foreach (var doc in container.Documents)
- Documents.Add(doc);
-
- SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
- }
-
- ///
- /// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding).
- ///
- partial void OnActiveDocumentChanged(MinintDocument? value)
- {
- if (_suppressDocumentSync) return;
- SyncLayersAndCanvas(value);
- }
-
- public void SelectDocument(MinintDocument? doc)
- {
- _suppressDocumentSync = true;
- ActiveDocument = doc;
- _suppressDocumentSync = false;
-
- SyncLayersAndCanvas(doc);
- }
-
- private void SyncLayersAndCanvas(MinintDocument? doc)
- {
- Layers.Clear();
- if (doc is not null)
- {
- foreach (var layer in doc.Layers)
- Layers.Add(layer);
- ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
- }
- else
- {
- ActiveLayer = null;
- }
-
- RefreshCanvas();
- }
-
- public void RefreshCanvas()
- {
- if (Container is null || ActiveDocument is null)
- {
- CanvasBitmap = null;
- return;
- }
-
- int w = Container.Width;
- int h = Container.Height;
- uint[] argb = _compositor.Composite(ActiveDocument, w, h);
-
- var bmp = new WriteableBitmap(
- new PixelSize(w, h),
- new Vector(96, 96),
- Avalonia.Platform.PixelFormat.Bgra8888);
-
- using (var fb = bmp.Lock())
- {
- unsafe
- {
- var dst = new Span((void*)fb.Address, w * h);
- for (int i = 0; i < argb.Length; i++)
- {
- // argb[i] is 0xAARRGGBB, need premultiplied BGRA for the bitmap
- uint px = argb[i];
- byte a = (byte)(px >> 24);
- byte r = (byte)((px >> 16) & 0xFF);
- byte g = (byte)((px >> 8) & 0xFF);
- byte b = (byte)(px & 0xFF);
-
- if (a == 255)
- {
- dst[i] = px; // ARGB layout == BGRA in LE memory, alpha=255 → no premul needed
- }
- else if (a == 0)
- {
- dst[i] = 0;
- }
- else
- {
- r = (byte)(r * a / 255);
- g = (byte)(g * a / 255);
- b = (byte)(b * a / 255);
- dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
- }
- }
- }
- }
-
- CanvasBitmap = bmp;
- }
-
- public ICompositor Compositor => _compositor;
- public IPaletteService PaletteService => _paletteService;
-}
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Minint.Core.Models;
+using Minint.Core.Services;
+using Minint.Core.Services.Impl;
+
+namespace Minint.ViewModels;
+
+public partial class EditorViewModel : ViewModelBase
+{
+ private readonly ICompositor _compositor = new Compositor();
+ private readonly IPaletteService _paletteService = new PaletteService();
+ private readonly IDrawingService _drawingService = new DrawingService();
+ private readonly IFloodFillService _floodFillService = new FloodFillService();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasContainer))]
+ [NotifyPropertyChangedFor(nameof(Title))]
+ private MinintContainer? _container;
+
+ [ObservableProperty]
+ private MinintDocument? _activeDocument;
+
+ [ObservableProperty]
+ private MinintLayer? _activeLayer;
+
+ private bool _suppressDocumentSync;
+
+ [ObservableProperty]
+ private WriteableBitmap? _canvasBitmap;
+
+ [ObservableProperty]
+ private bool _showGrid;
+
+ // Tool state
+ [ObservableProperty]
+ private ToolType _activeTool = ToolType.Brush;
+
+ [ObservableProperty]
+ private int _brushRadius = 1;
+
+ ///
+ /// Pixel coordinates of current brush/eraser preview center, or null if cursor is outside image.
+ ///
+ [ObservableProperty]
+ private (int X, int Y)? _previewCenter;
+
+ private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
+
+ ///
+ /// Avalonia Color bound two-way to the ColorPicker in the toolbar.
+ ///
+ public Avalonia.Media.Color PreviewColor
+ {
+ get => _previewColor;
+ set
+ {
+ if (_previewColor == value) return;
+ _previewColor = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SelectedColor));
+ }
+ }
+
+ public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(Title))]
+ private string? _filePath;
+
+ public bool HasContainer => Container is not null;
+
+ public string Title => FilePath is not null
+ ? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
+ : Container is not null
+ ? "Minint — Untitled"
+ : "Minint";
+
+ public ObservableCollection Documents { get; } = [];
+ public ObservableCollection Layers { get; } = [];
+
+ #region Container / Document management
+
+ public void NewContainer(int width, int height)
+ {
+ var c = new MinintContainer(width, height);
+ c.AddNewDocument("Document 1");
+ LoadContainer(c, null);
+ }
+
+ public void LoadContainer(MinintContainer container, string? path)
+ {
+ Container = container;
+ FilePath = path;
+
+ Documents.Clear();
+ foreach (var doc in container.Documents)
+ Documents.Add(doc);
+
+ SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
+ }
+
+ partial void OnActiveDocumentChanged(MinintDocument? value)
+ {
+ if (_suppressDocumentSync) return;
+ SyncLayersAndCanvas(value);
+ }
+
+ public void SelectDocument(MinintDocument? doc)
+ {
+ _suppressDocumentSync = true;
+ ActiveDocument = doc;
+ _suppressDocumentSync = false;
+ SyncLayersAndCanvas(doc);
+ }
+
+ private void SyncLayersAndCanvas(MinintDocument? doc)
+ {
+ Layers.Clear();
+ if (doc is not null)
+ {
+ foreach (var layer in doc.Layers)
+ Layers.Add(layer);
+ ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
+ }
+ else
+ {
+ ActiveLayer = null;
+ }
+ RefreshCanvas();
+ }
+
+ #endregion
+
+ #region Drawing
+
+ ///
+ /// Called by PixelCanvas on left-click at the given image pixel coordinate.
+ ///
+ public void OnToolDown(int px, int py)
+ {
+ if (Container is null || ActiveDocument is null || ActiveLayer is null)
+ return;
+
+ int w = Container.Width, h = Container.Height;
+ if (px < 0 || px >= w || py < 0 || py >= h)
+ return;
+
+ switch (ActiveTool)
+ {
+ case ToolType.Brush:
+ {
+ int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
+ _drawingService.ApplyBrush(ActiveLayer, px, py, BrushRadius, colorIdx, w, h);
+ break;
+ }
+ case ToolType.Eraser:
+ _drawingService.ApplyEraser(ActiveLayer, px, py, BrushRadius, w, h);
+ break;
+ case ToolType.Fill:
+ {
+ int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
+ _floodFillService.Fill(ActiveLayer, px, py, colorIdx, w, h);
+ break;
+ }
+ }
+
+ RefreshCanvas();
+ }
+
+ ///
+ /// Called by PixelCanvas on left-drag at the given image pixel coordinate.
+ /// Same as OnToolDown for brush/eraser, no-op for fill.
+ ///
+ public void OnToolDrag(int px, int py)
+ {
+ if (ActiveTool == ToolType.Fill) return;
+ OnToolDown(px, py);
+ }
+
+ ///
+ /// Returns brush mask pixels for tool preview overlay.
+ ///
+ public List<(int X, int Y)>? GetPreviewMask()
+ {
+ if (PreviewCenter is null || Container is null)
+ return null;
+ if (ActiveTool == ToolType.Fill)
+ return null;
+
+ var (cx, cy) = PreviewCenter.Value;
+ return _drawingService.GetBrushMask(cx, cy, BrushRadius, Container.Width, Container.Height);
+ }
+
+ [RelayCommand]
+ private void SelectBrush() => ActiveTool = ToolType.Brush;
+
+ [RelayCommand]
+ private void SelectEraser() => ActiveTool = ToolType.Eraser;
+
+ [RelayCommand]
+ private void SelectFill() => ActiveTool = ToolType.Fill;
+
+ #endregion
+
+ #region Canvas rendering
+
+ public void RefreshCanvas()
+ {
+ if (Container is null || ActiveDocument is null)
+ {
+ CanvasBitmap = null;
+ return;
+ }
+
+ int w = Container.Width;
+ int h = Container.Height;
+ uint[] argb = _compositor.Composite(ActiveDocument, w, h);
+
+ var bmp = new WriteableBitmap(
+ new PixelSize(w, h),
+ new Vector(96, 96),
+ Avalonia.Platform.PixelFormat.Bgra8888);
+
+ using (var fb = bmp.Lock())
+ {
+ unsafe
+ {
+ var dst = new Span((void*)fb.Address, w * h);
+ for (int i = 0; i < argb.Length; i++)
+ {
+ uint px = argb[i];
+ byte a = (byte)(px >> 24);
+ byte r = (byte)((px >> 16) & 0xFF);
+ byte g = (byte)((px >> 8) & 0xFF);
+ byte b = (byte)(px & 0xFF);
+
+ if (a == 255)
+ {
+ dst[i] = px;
+ }
+ else if (a == 0)
+ {
+ dst[i] = 0;
+ }
+ else
+ {
+ r = (byte)(r * a / 255);
+ g = (byte)(g * a / 255);
+ b = (byte)(b * a / 255);
+ dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
+ }
+ }
+ }
+ }
+
+ CanvasBitmap = bmp;
+ }
+
+ #endregion
+
+ public ICompositor Compositor => _compositor;
+ public IPaletteService PaletteService => _paletteService;
+ public IDrawingService DrawingService => _drawingService;
+}
diff --git a/Minint/ViewModels/ToolType.cs b/Minint/ViewModels/ToolType.cs
new file mode 100644
index 0000000..a04b309
--- /dev/null
+++ b/Minint/ViewModels/ToolType.cs
@@ -0,0 +1,8 @@
+namespace Minint.ViewModels;
+
+public enum ToolType
+{
+ Brush,
+ Eraser,
+ Fill
+}
diff --git a/Minint/ViewModels/ToolTypeConverters.cs b/Minint/ViewModels/ToolTypeConverters.cs
new file mode 100644
index 0000000..ec1d701
--- /dev/null
+++ b/Minint/ViewModels/ToolTypeConverters.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+
+namespace Minint.ViewModels;
+
+///
+/// Static IValueConverter instances for binding RadioButton.IsChecked to ToolType.
+/// These are one-way (read-only) — the RadioButton Command sets the actual value.
+///
+public static class ToolTypeConverters
+{
+ public static readonly IValueConverter IsBrush = new ToolTypeConverter(ToolType.Brush);
+ public static readonly IValueConverter IsEraser = new ToolTypeConverter(ToolType.Eraser);
+ public static readonly IValueConverter IsFill = new ToolTypeConverter(ToolType.Fill);
+
+ private sealed class ToolTypeConverter(ToolType target) : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is ToolType t && t == target;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => target;
+ }
+}
diff --git a/Minint/Views/MainWindow.axaml b/Minint/Views/MainWindow.axaml
index 1c3474c..0cffe84 100644
--- a/Minint/Views/MainWindow.axaml
+++ b/Minint/Views/MainWindow.axaml
@@ -1,95 +1,129 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Minint/Views/MainWindow.axaml.cs b/Minint/Views/MainWindow.axaml.cs
index 6b5adbe..915ea45 100644
--- a/Minint/Views/MainWindow.axaml.cs
+++ b/Minint/Views/MainWindow.axaml.cs
@@ -1,34 +1,45 @@
-using System;
-using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
-using Minint.Controls;
-using Minint.ViewModels;
-
-namespace Minint.Views;
-
-public partial class MainWindow : Window
-{
- public MainWindow()
- {
- InitializeComponent();
- }
-
- protected override void OnOpened(EventArgs e)
- {
- base.OnOpened(e);
-
- var canvas = this.FindControl("Canvas");
- var hScroll = this.FindControl("HScroll");
- var vScroll = this.FindControl("VScroll");
-
- if (canvas is not null && hScroll is not null && vScroll is not null)
- canvas.AttachScrollBars(hScroll, vScroll);
- }
-
- protected override void OnDataContextChanged(EventArgs e)
- {
- base.OnDataContextChanged(e);
- if (DataContext is MainWindowViewModel vm)
- vm.Owner = this;
- }
-}
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Minint.Controls;
+using Minint.ViewModels;
+
+namespace Minint.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnOpened(EventArgs e)
+ {
+ base.OnOpened(e);
+
+ var canvas = this.FindControl("Canvas");
+ var hScroll = this.FindControl("HScroll");
+ var vScroll = this.FindControl("VScroll");
+
+ if (canvas is not null && hScroll is not null && vScroll is not null)
+ canvas.AttachScrollBars(hScroll, vScroll);
+
+ if (canvas is not null && DataContext is MainWindowViewModel vm)
+ WireCanvasEvents(canvas, vm.Editor);
+ }
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is MainWindowViewModel vm)
+ vm.Owner = this;
+ }
+
+ private static void WireCanvasEvents(PixelCanvas canvas, EditorViewModel editor)
+ {
+ canvas.ToolDown += (px, py) => editor.OnToolDown(px, py);
+ canvas.ToolDrag += (px, py) => editor.OnToolDrag(px, py);
+ canvas.CursorPixelChanged += pixel => editor.PreviewCenter = pixel;
+ canvas.GetPreviewMask = () => editor.GetPreviewMask();
+ }
+}