From 25e30416a37007bbfe3ce56d959304afbbfacda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 29 Mar 2026 16:42:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Minint.Core/Services/Impl/DrawingService.cs | 43 ++ Minint.Core/Services/Impl/FloodFillService.cs | 47 ++ Minint/App.axaml | 1 + Minint/Controls/PixelCanvas.cs | 730 ++++++++++-------- Minint/Minint.csproj | 65 +- Minint/ViewModels/EditorViewModel.cs | 433 +++++++---- Minint/ViewModels/ToolType.cs | 8 + Minint/ViewModels/ToolTypeConverters.cs | 25 + Minint/Views/MainWindow.axaml | 224 +++--- Minint/Views/MainWindow.axaml.cs | 79 +- 10 files changed, 1006 insertions(+), 649 deletions(-) create mode 100644 Minint.Core/Services/Impl/DrawingService.cs create mode 100644 Minint.Core/Services/Impl/FloodFillService.cs create mode 100644 Minint/ViewModels/ToolType.cs create mode 100644 Minint/ViewModels/ToolTypeConverters.cs 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(); + } +}