Этап 7
This commit is contained in:
43
Minint.Core/Services/Impl/DrawingService.cs
Normal file
43
Minint.Core/Services/Impl/DrawingService.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
47
Minint.Core/Services/Impl/FloodFillService.cs
Normal file
47
Minint.Core/Services/Impl/FloodFillService.cs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,6 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom).
|
||||
/// Supports nearest-neighbor scaling, pixel grid overlay, scrollbars, and mouse interaction.
|
||||
/// <para>Input model:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Ctrl+Wheel: zoom at cursor</item>
|
||||
/// <item>Wheel: scroll vertically</item>
|
||||
/// <item>Shift+Wheel: scroll horizontally</item>
|
||||
/// <item>Touchpad two-finger scroll: free pan (Delta.X + Delta.Y)</item>
|
||||
/// <item>Middle mouse drag: pan</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class PixelCanvas : Control
|
||||
{
|
||||
#region Styled Properties
|
||||
|
||||
public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty =
|
||||
AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap));
|
||||
|
||||
public static readonly StyledProperty<bool> ShowGridProperty =
|
||||
AvaloniaProperty.Register<PixelCanvas, bool>(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<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
|
||||
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true);
|
||||
}
|
||||
|
||||
public PixelCanvas()
|
||||
{
|
||||
ClipToBounds = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects external ScrollBar controls. Call once after the UI is built.
|
||||
/// </summary>
|
||||
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<WriteableBitmap?> SourceBitmapProperty =
|
||||
AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap));
|
||||
|
||||
public static readonly StyledProperty<bool> ShowGridProperty =
|
||||
AvaloniaProperty.Register<PixelCanvas, bool>(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
|
||||
|
||||
/// <summary>Fires when the user presses the left mouse button at an image pixel.</summary>
|
||||
public event Action<int, int>? ToolDown;
|
||||
|
||||
/// <summary>Fires when the user drags with left button held at an image pixel.</summary>
|
||||
public event Action<int, int>? ToolDrag;
|
||||
|
||||
/// <summary>Fires when the cursor moves over the image (pixel coords, or null if outside).</summary>
|
||||
public event Action<(int X, int Y)?>? CursorPixelChanged;
|
||||
|
||||
/// <summary>Set by the host to provide preview mask pixels for overlay.</summary>
|
||||
public Func<List<(int X, int Y)>?>? 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<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
|
||||
FocusableProperty.OverrideDefaultValue<PixelCanvas>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Minint.Core\Minint.Core.csproj" />
|
||||
<ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Minint.Core\Minint.Core.csproj" />
|
||||
<ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.8" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Path of the currently open file, or null for unsaved new containers.
|
||||
/// </summary>
|
||||
[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<MinintDocument> Documents { get; } = [];
|
||||
public ObservableCollection<MinintLayer> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding).
|
||||
/// </summary>
|
||||
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<uint>((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;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel coordinates of current brush/eraser preview center, or null if cursor is outside image.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private (int X, int Y)? _previewCenter;
|
||||
|
||||
private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia Color bound two-way to the ColorPicker in the toolbar.
|
||||
/// </summary>
|
||||
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<MinintDocument> Documents { get; } = [];
|
||||
public ObservableCollection<MinintLayer> 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
|
||||
|
||||
/// <summary>
|
||||
/// Called by PixelCanvas on left-click at the given image pixel coordinate.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by PixelCanvas on left-drag at the given image pixel coordinate.
|
||||
/// Same as OnToolDown for brush/eraser, no-op for fill.
|
||||
/// </summary>
|
||||
public void OnToolDrag(int px, int py)
|
||||
{
|
||||
if (ActiveTool == ToolType.Fill) return;
|
||||
OnToolDown(px, py);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns brush mask pixels for tool preview overlay.
|
||||
/// </summary>
|
||||
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<uint>((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;
|
||||
}
|
||||
|
||||
8
Minint/ViewModels/ToolType.cs
Normal file
8
Minint/ViewModels/ToolType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Minint.ViewModels;
|
||||
|
||||
public enum ToolType
|
||||
{
|
||||
Brush,
|
||||
Eraser,
|
||||
Fill
|
||||
}
|
||||
25
Minint/ViewModels/ToolTypeConverters.cs
Normal file
25
Minint/ViewModels/ToolTypeConverters.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace Minint.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Static IValueConverter instances for binding RadioButton.IsChecked to ToolType.
|
||||
/// These are one-way (read-only) — the RadioButton Command sets the actual value.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,129 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Minint.ViewModels"
|
||||
xmlns:controls="using:Minint.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="700"
|
||||
x:Class="Minint.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="{Binding Editor.Title}"
|
||||
Width="1024" Height="700">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<DockPanel>
|
||||
<!-- Menu bar -->
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
|
||||
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
||||
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
||||
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<!-- Status bar -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
Padding="8,2">
|
||||
<TextBlock Text="{Binding StatusText}" FontSize="12"/>
|
||||
</Border>
|
||||
|
||||
<!-- Main content: left panel, canvas, right panel -->
|
||||
<Grid ColumnDefinitions="180,*,180">
|
||||
|
||||
<!-- Left panel: documents -->
|
||||
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,1,0" Padding="4">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<ListBox ItemsSource="{Binding Editor.Documents}"
|
||||
SelectedItem="{Binding Editor.ActiveDocument}"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Center: canvas with scrollbars -->
|
||||
<Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto">
|
||||
<Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True">
|
||||
<controls:PixelCanvas x:Name="Canvas"
|
||||
SourceBitmap="{Binding Editor.CanvasBitmap}"
|
||||
ShowGrid="{Binding Editor.ShowGrid}"/>
|
||||
</Border>
|
||||
<ScrollBar x:Name="HScroll" Grid.Row="1" Grid.Column="0"
|
||||
Orientation="Horizontal"/>
|
||||
<ScrollBar x:Name="VScroll" Grid.Row="0" Grid.Column="1"
|
||||
Orientation="Vertical"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Right panel: layers -->
|
||||
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="1,0,0,0" Padding="4">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<ListBox ItemsSource="{Binding Editor.Layers}"
|
||||
SelectedItem="{Binding Editor.ActiveLayer}"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"/>
|
||||
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Minint.ViewModels"
|
||||
xmlns:controls="using:Minint.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="700"
|
||||
x:Class="Minint.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="{Binding Editor.Title}"
|
||||
Width="1024" Height="700">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<DockPanel>
|
||||
<!-- Menu bar -->
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
|
||||
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
||||
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
||||
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="6,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<RadioButton GroupName="Tool" Content="Brush"
|
||||
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsBrush}}"
|
||||
Command="{Binding Editor.SelectBrushCommand}"/>
|
||||
<RadioButton GroupName="Tool" Content="Eraser"
|
||||
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsEraser}}"
|
||||
Command="{Binding Editor.SelectEraserCommand}"/>
|
||||
<RadioButton GroupName="Tool" Content="Fill"
|
||||
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
|
||||
Command="{Binding Editor.SelectFillCommand}"/>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<TextBlock Text="Size:" VerticalAlignment="Center"/>
|
||||
<Slider Value="{Binding Editor.BrushRadius}" Minimum="0" Maximum="64"
|
||||
TickFrequency="1" IsSnapToTickEnabled="True" Width="120"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Editor.BrushRadius}" VerticalAlignment="Center"
|
||||
Width="20" TextAlignment="Center"/>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<TextBlock Text="Color:" VerticalAlignment="Center"/>
|
||||
<ColorPicker x:Name="ToolColorPicker"
|
||||
Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
|
||||
IsAlphaVisible="False"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Status bar -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||
Padding="8,2">
|
||||
<TextBlock Text="{Binding StatusText}" FontSize="12"/>
|
||||
</Border>
|
||||
|
||||
<!-- Main content: left panel, canvas, right panel -->
|
||||
<Grid ColumnDefinitions="180,*,180">
|
||||
|
||||
<!-- Left panel: documents -->
|
||||
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,1,0" Padding="4">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<ListBox ItemsSource="{Binding Editor.Documents}"
|
||||
SelectedItem="{Binding Editor.ActiveDocument}"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Center: canvas with scrollbars -->
|
||||
<Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto">
|
||||
<Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True">
|
||||
<controls:PixelCanvas x:Name="Canvas"
|
||||
SourceBitmap="{Binding Editor.CanvasBitmap}"
|
||||
ShowGrid="{Binding Editor.ShowGrid}"/>
|
||||
</Border>
|
||||
<ScrollBar x:Name="HScroll" Grid.Row="1" Grid.Column="0"
|
||||
Orientation="Horizontal"/>
|
||||
<ScrollBar x:Name="VScroll" Grid.Row="0" Grid.Column="1"
|
||||
Orientation="Vertical"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Right panel: layers -->
|
||||
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="1,0,0,0" Padding="4">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<ListBox ItemsSource="{Binding Editor.Layers}"
|
||||
SelectedItem="{Binding Editor.ActiveLayer}"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"/>
|
||||
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@@ -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<PixelCanvas>("Canvas");
|
||||
var hScroll = this.FindControl<ScrollBar>("HScroll");
|
||||
var vScroll = this.FindControl<ScrollBar>("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<PixelCanvas>("Canvas");
|
||||
var hScroll = this.FindControl<ScrollBar>("HScroll");
|
||||
var vScroll = this.FindControl<ScrollBar>("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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user