Этап 7

This commit is contained in:
2026-03-29 16:42:40 +03:00
parent 9ef5ad8f68
commit 25e30416a3
10 changed files with 1006 additions and 649 deletions

View 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;
}
}

View 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));
}
}
}
}

View File

@@ -11,5 +11,6 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml"/>
</Application.Styles> </Application.Styles>
</Application> </Application>

View File

@@ -1,325 +1,405 @@
using System; using System;
using Avalonia; using System.Collections.Generic;
using Avalonia.Controls; using Avalonia;
using Avalonia.Controls.Primitives; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Controls.Primitives;
using Avalonia.Media; using Avalonia.Input;
using Avalonia.Media.Imaging; using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace Minint.Controls;
namespace Minint.Controls;
/// <summary>
/// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom). public class PixelCanvas : Control
/// Supports nearest-neighbor scaling, pixel grid overlay, scrollbars, and mouse interaction. {
/// <para>Input model:</para> #region Styled Properties
/// <list type="bullet">
/// <item>Ctrl+Wheel: zoom at cursor</item> public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty =
/// <item>Wheel: scroll vertically</item> AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap));
/// <item>Shift+Wheel: scroll horizontally</item>
/// <item>Touchpad two-finger scroll: free pan (Delta.X + Delta.Y)</item> public static readonly StyledProperty<bool> ShowGridProperty =
/// <item>Middle mouse drag: pan</item> AvaloniaProperty.Register<PixelCanvas, bool>(nameof(ShowGrid), defaultValue: false);
/// </list>
/// </summary> public WriteableBitmap? SourceBitmap
public class PixelCanvas : Control {
{ get => GetValue(SourceBitmapProperty);
#region Styled Properties set => SetValue(SourceBitmapProperty, value);
}
public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty =
AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap)); public bool ShowGrid
{
public static readonly StyledProperty<bool> ShowGridProperty = get => GetValue(ShowGridProperty);
AvaloniaProperty.Register<PixelCanvas, bool>(nameof(ShowGrid), defaultValue: false); set => SetValue(ShowGridProperty, value);
}
public WriteableBitmap? SourceBitmap
{ #endregion
get => GetValue(SourceBitmapProperty);
set => SetValue(SourceBitmapProperty, value); #region Events for tool interaction
}
/// <summary>Fires when the user presses the left mouse button at an image pixel.</summary>
public bool ShowGrid public event Action<int, int>? ToolDown;
{
get => GetValue(ShowGridProperty); /// <summary>Fires when the user drags with left button held at an image pixel.</summary>
set => SetValue(ShowGridProperty, value); public event Action<int, int>? ToolDrag;
}
/// <summary>Fires when the cursor moves over the image (pixel coords, or null if outside).</summary>
#endregion public event Action<(int X, int Y)?>? CursorPixelChanged;
private readonly Viewport _viewport = new(); /// <summary>Set by the host to provide preview mask pixels for overlay.</summary>
private bool _isPanning; public Func<List<(int X, int Y)>?>? GetPreviewMask { get; set; }
private Point _panStart;
private double _panStartOffsetX, _panStartOffsetY; #endregion
private bool _viewportInitialized;
private readonly Viewport _viewport = new();
private ScrollBar? _hScrollBar; private bool _isPanning;
private ScrollBar? _vScrollBar; private bool _isDrawing;
private bool _suppressScrollSync; private Point _panStart;
private double _panStartOffsetX, _panStartOffsetY;
private const double ScrollPixelsPerTick = 20.0; private bool _viewportInitialized;
private (int X, int Y)? _lastCursorPixel;
public Viewport Viewport => _viewport;
private ScrollBar? _hScrollBar;
static PixelCanvas() private ScrollBar? _vScrollBar;
{ private bool _suppressScrollSync;
AffectsRender<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true); private const double ScrollPixelsPerTick = 20.0;
}
public Viewport Viewport => _viewport;
public PixelCanvas()
{ static PixelCanvas()
ClipToBounds = true; {
} AffectsRender<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true);
/// <summary> }
/// Connects external ScrollBar controls. Call once after the UI is built.
/// </summary> public PixelCanvas()
public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical) {
{ ClipToBounds = true;
if (_hScrollBar is not null) }
_hScrollBar.ValueChanged -= OnHScrollChanged;
if (_vScrollBar is not null) public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
_vScrollBar.ValueChanged -= OnVScrollChanged; {
if (_hScrollBar is not null) _hScrollBar.ValueChanged -= OnHScrollChanged;
_hScrollBar = horizontal; if (_vScrollBar is not null) _vScrollBar.ValueChanged -= OnVScrollChanged;
_vScrollBar = vertical; _hScrollBar = horizontal;
_vScrollBar = vertical;
_hScrollBar.ValueChanged += OnHScrollChanged; _hScrollBar.ValueChanged += OnHScrollChanged;
_vScrollBar.ValueChanged += OnVScrollChanged; _vScrollBar.ValueChanged += OnVScrollChanged;
} }
#region Rendering #region Rendering
public override void Render(DrawingContext context) public override void Render(DrawingContext context)
{ {
base.Render(context); base.Render(context);
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
var bmp = SourceBitmap; var bmp = SourceBitmap;
if (bmp is null) if (bmp is null) return;
return;
int imgW = bmp.PixelSize.Width;
int imgW = bmp.PixelSize.Width; int imgH = bmp.PixelSize.Height;
int imgH = bmp.PixelSize.Height;
if (!_viewportInitialized)
if (!_viewportInitialized) {
{ _viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
_viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height); _viewportInitialized = true;
_viewportInitialized = true; }
}
DrawCheckerboard(context, imgW, imgH);
DrawCheckerboard(context, imgW, imgH);
var destRect = _viewport.ImageScreenRect(imgW, imgH);
var destRect = _viewport.ImageScreenRect(imgW, imgH); var srcRect = new Rect(0, 0, imgW, imgH);
var srcRect = new Rect(0, 0, imgW, imgH); RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None); context.DrawImage(bmp, srcRect, destRect);
context.DrawImage(bmp, srcRect, destRect);
if (ShowGrid && _viewport.Zoom >= 4)
if (ShowGrid && _viewport.Zoom >= 4) DrawPixelGrid(context, imgW, imgH);
DrawPixelGrid(context, imgW, imgH);
DrawToolPreview(context, imgW, imgH);
// Defer scrollbar sync — updating layout properties during Render is forbidden
int w = imgW, h = imgH; int w = imgW, h = imgH;
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render); Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
} }
private void DrawCheckerboard(DrawingContext context, int imgW, int imgH) private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
{ {
var rect = _viewport.ImageScreenRect(imgW, imgH); var rect = _viewport.ImageScreenRect(imgW, imgH);
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height); var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
var visible = rect.Intersect(clip); var visible = rect.Intersect(clip);
if (visible.Width <= 0 || visible.Height <= 0) return; if (visible.Width <= 0 || visible.Height <= 0) return;
const int checkerSize = 8; const int checkerSize = 8;
var light = new SolidColorBrush(Color.FromRgb(204, 204, 204)); var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170)); var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
using (context.PushClip(visible)) using (context.PushClip(visible))
{ {
context.FillRectangle(light, visible); context.FillRectangle(light, visible);
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2)); double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2)); for (double y = startY; y < visible.Bottom; y += checkerSize)
{
for (double y = startY; y < visible.Bottom; y += checkerSize) for (double x = startX; x < visible.Right; x += checkerSize)
{ {
for (double x = startX; x < visible.Right; x += checkerSize) int col = (int)((x - rect.X) / checkerSize);
{ int row = (int)((y - rect.Y) / checkerSize);
int col = (int)((x - rect.X) / checkerSize); if ((col + row) % 2 == 1)
int row = (int)((y - rect.Y) / checkerSize); context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize));
if ((col + row) % 2 == 1) }
context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize)); }
} }
} }
}
} private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
{
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 pen = new Pen(new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)), 1); var imgRect = _viewport.ImageScreenRect(imgW, imgH);
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height); var visible = imgRect.Intersect(clip);
var imgRect = _viewport.ImageScreenRect(imgW, imgH); if (visible.Width <= 0 || visible.Height <= 0) return;
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);
var (startPx, startPy) = _viewport.ScreenToPixel(visible.X, visible.Y); startPx = Math.Max(0, startPx);
var (endPx, endPy) = _viewport.ScreenToPixel(visible.Right, visible.Bottom); startPy = Math.Max(0, startPy);
startPx = Math.Max(0, startPx); endPx = Math.Min(imgW, endPx + 1);
startPy = Math.Max(0, startPy); endPy = Math.Min(imgH, endPy + 1);
endPx = Math.Min(imgW, endPx + 1);
endPy = Math.Min(imgH, endPy + 1); using (context.PushClip(visible))
{
using (context.PushClip(visible)) for (int px = startPx; px <= endPx; px++)
{ {
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));
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);
for (int py = startPy; py <= endPy; py++) context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
{ }
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();
#endregion if (mask is null || mask.Count == 0) return;
#region Scrollbar Sync double zoom = _viewport.Zoom;
var previewBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255));
private void SyncScrollBars(int imgW, int imgH) var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), 1);
{
if (_hScrollBar is null || _vScrollBar is null) return; var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
using (context.PushClip(clip))
_suppressScrollSync = true; {
foreach (var (px, py) in mask)
// Scrollbar value is negated offset: increasing value = scroll right = offset decreases {
var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX); var (sx, sy) = _viewport.PixelToScreen(px, py);
_hScrollBar.Minimum = -hMax; var r = new Rect(sx, sy, zoom, zoom);
_hScrollBar.Maximum = -hMin; context.FillRectangle(previewBrush, r);
_hScrollBar.Value = -hVal; }
_hScrollBar.ViewportSize = hView;
// Outline around the mask bounding box
var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY); if (mask.Count > 0)
_vScrollBar.Minimum = -vMax; {
_vScrollBar.Maximum = -vMin; int minX = mask[0].X, maxX = mask[0].X;
_vScrollBar.Value = -vVal; int minY = mask[0].Y, maxY = mask[0].Y;
_vScrollBar.ViewportSize = vView; foreach (var (px, py) in mask)
{
_suppressScrollSync = false; if (px < minX) minX = px;
} if (px > maxX) maxX = px;
if (py < minY) minY = py;
private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e) if (py > maxY) maxY = py;
{ }
if (_suppressScrollSync) return; var (ox, oy) = _viewport.PixelToScreen(minX, minY);
var (imgW, imgH) = GetImageSize(); var outlineRect = new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom);
_viewport.SetOffset(-e.NewValue, _viewport.OffsetY, context.DrawRectangle(outlinePen, outlineRect);
imgW, imgH, Bounds.Width, Bounds.Height); }
InvalidateVisual(); }
} }
private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e) #endregion
{
if (_suppressScrollSync) return; #region Scrollbar Sync
var (imgW, imgH) = GetImageSize();
_viewport.SetOffset(_viewport.OffsetX, -e.NewValue, private void SyncScrollBars(int imgW, int imgH)
imgW, imgH, Bounds.Width, Bounds.Height); {
InvalidateVisual(); if (_hScrollBar is null || _vScrollBar is null) return;
} _suppressScrollSync = true;
#endregion var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
_hScrollBar.Minimum = -hMax;
#region Mouse Input _hScrollBar.Maximum = -hMin;
_hScrollBar.Value = -hVal;
private (int W, int H) GetImageSize() _hScrollBar.ViewportSize = hView;
{
var bmp = SourceBitmap; var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0); _vScrollBar.Minimum = -vMax;
} _vScrollBar.Maximum = -vMin;
_vScrollBar.Value = -vVal;
protected override void OnPointerWheelChanged(PointerWheelEventArgs e) _vScrollBar.ViewportSize = vView;
{
base.OnPointerWheelChanged(e); _suppressScrollSync = false;
var (imgW, imgH) = GetImageSize(); }
if (imgW == 0) return;
private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0; {
bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0; if (_suppressScrollSync) return;
var (imgW, imgH) = GetImageSize();
if (ctrl) _viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height);
{ InvalidateVisual();
var pos = e.GetPosition(this); }
_viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y,
imgW, imgH, Bounds.Width, Bounds.Height); private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
} {
else if (_suppressScrollSync) return;
{ var (imgW, imgH) = GetImageSize();
double dx = e.Delta.X * ScrollPixelsPerTick; _viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height);
double dy = e.Delta.Y * ScrollPixelsPerTick; InvalidateVisual();
}
if (shift && Math.Abs(e.Delta.X) < 0.001)
{ #endregion
dx = dy;
dy = 0; #region Mouse Input
}
private (int W, int H) GetImageSize()
_viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height); {
} var bmp = SourceBitmap;
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
InvalidateVisual(); }
e.Handled = true;
} private (int X, int Y)? ScreenToPixelClamped(Point pos)
{
protected override void OnPointerPressed(PointerPressedEventArgs e) var (imgW, imgH) = GetImageSize();
{ if (imgW == 0) return null;
base.OnPointerPressed(e); var (px, py) = _viewport.ScreenToPixel(pos.X, pos.Y);
if (px < 0 || px >= imgW || py < 0 || py >= imgH)
if (e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed) return null;
{ return (px, py);
_isPanning = true; }
_panStart = e.GetPosition(this);
_panStartOffsetX = _viewport.OffsetX; protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
_panStartOffsetY = _viewport.OffsetY; {
e.Handled = true; base.OnPointerWheelChanged(e);
} var (imgW, imgH) = GetImageSize();
} if (imgW == 0) return;
protected override void OnPointerMoved(PointerEventArgs e) bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
{ bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
base.OnPointerMoved(e);
if (ctrl)
if (_isPanning) {
{ var pos = e.GetPosition(this);
var pos = e.GetPosition(this); _viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, imgW, imgH, Bounds.Width, Bounds.Height);
var (imgW, imgH) = GetImageSize(); }
_viewport.SetOffset( else
_panStartOffsetX + (pos.X - _panStart.X), {
_panStartOffsetY + (pos.Y - _panStart.Y), double dx = e.Delta.X * ScrollPixelsPerTick;
imgW, imgH, Bounds.Width, Bounds.Height); double dy = e.Delta.Y * ScrollPixelsPerTick;
InvalidateVisual(); if (shift && Math.Abs(e.Delta.X) < 0.001)
e.Handled = true; {
} dx = dy;
} dy = 0;
}
protected override void OnPointerReleased(PointerReleasedEventArgs e) _viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
{ }
base.OnPointerReleased(e);
InvalidateVisual();
if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle) e.Handled = true;
{ }
_isPanning = false;
e.Handled = true; protected override void OnPointerPressed(PointerPressedEventArgs e)
} {
} base.OnPointerPressed(e);
var props = e.GetCurrentPoint(this).Properties;
#endregion
if (props.IsMiddleButtonPressed)
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) {
{ _isPanning = true;
base.OnPropertyChanged(change); _panStart = e.GetPosition(this);
_panStartOffsetX = _viewport.OffsetX;
if (change.Property == SourceBitmapProperty) _panStartOffsetY = _viewport.OffsetY;
_viewportInitialized = false; 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;
}
}

View File

@@ -1,32 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Minint.Core\Minint.Core.csproj" /> <ProjectReference Include="..\Minint.Core\Minint.Core.csproj" />
<ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" /> <ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.8" /> <PackageReference Include="Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" 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.Fonts.Inter" Version="11.3.8" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8"> <PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> <PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.8" />
</ItemGroup> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
</Project> </ItemGroup>
</Project>

View File

@@ -1,163 +1,270 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.Generic;
using Avalonia; using System.Collections.ObjectModel;
using Avalonia.Media.Imaging; using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using Avalonia.Media.Imaging;
using Minint.Core.Models; using CommunityToolkit.Mvvm.ComponentModel;
using Minint.Core.Services; using CommunityToolkit.Mvvm.Input;
using Minint.Core.Services.Impl; using Minint.Core.Models;
using Minint.Core.Services;
namespace Minint.ViewModels; using Minint.Core.Services.Impl;
public partial class EditorViewModel : ViewModelBase namespace Minint.ViewModels;
{
private readonly ICompositor _compositor = new Compositor(); public partial class EditorViewModel : ViewModelBase
private readonly IPaletteService _paletteService = new PaletteService(); {
private readonly ICompositor _compositor = new Compositor();
[ObservableProperty] private readonly IPaletteService _paletteService = new PaletteService();
[NotifyPropertyChangedFor(nameof(HasContainer))] private readonly IDrawingService _drawingService = new DrawingService();
[NotifyPropertyChangedFor(nameof(Title))] private readonly IFloodFillService _floodFillService = new FloodFillService();
private MinintContainer? _container;
[ObservableProperty]
[ObservableProperty] [NotifyPropertyChangedFor(nameof(HasContainer))]
private MinintDocument? _activeDocument; [NotifyPropertyChangedFor(nameof(Title))]
private MinintContainer? _container;
[ObservableProperty]
private MinintLayer? _activeLayer; [ObservableProperty]
private MinintDocument? _activeDocument;
private bool _suppressDocumentSync;
[ObservableProperty]
[ObservableProperty] private MinintLayer? _activeLayer;
private WriteableBitmap? _canvasBitmap;
private bool _suppressDocumentSync;
[ObservableProperty]
private bool _showGrid; [ObservableProperty]
private WriteableBitmap? _canvasBitmap;
/// <summary>
/// Path of the currently open file, or null for unsaved new containers. [ObservableProperty]
/// </summary> private bool _showGrid;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Title))] // Tool state
private string? _filePath; [ObservableProperty]
private ToolType _activeTool = ToolType.Brush;
public bool HasContainer => Container is not null;
[ObservableProperty]
public string Title => FilePath is not null private int _brushRadius = 1;
? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
: Container is not null /// <summary>
? "Minint — Untitled" /// Pixel coordinates of current brush/eraser preview center, or null if cursor is outside image.
: "Minint"; /// </summary>
[ObservableProperty]
public ObservableCollection<MinintDocument> Documents { get; } = []; private (int X, int Y)? _previewCenter;
public ObservableCollection<MinintLayer> Layers { get; } = [];
private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
public void NewContainer(int width, int height)
{ /// <summary>
var c = new MinintContainer(width, height); /// Avalonia Color bound two-way to the ColorPicker in the toolbar.
c.AddNewDocument("Document 1"); /// </summary>
LoadContainer(c, null); public Avalonia.Media.Color PreviewColor
} {
get => _previewColor;
public void LoadContainer(MinintContainer container, string? path) set
{ {
Container = container; if (_previewColor == value) return;
FilePath = path; _previewColor = value;
OnPropertyChanged();
Documents.Clear(); OnPropertyChanged(nameof(SelectedColor));
foreach (var doc in container.Documents) }
Documents.Add(doc); }
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null); public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
}
[ObservableProperty]
/// <summary> [NotifyPropertyChangedFor(nameof(Title))]
/// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding). private string? _filePath;
/// </summary>
partial void OnActiveDocumentChanged(MinintDocument? value) public bool HasContainer => Container is not null;
{
if (_suppressDocumentSync) return; public string Title => FilePath is not null
SyncLayersAndCanvas(value); ? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
} : Container is not null
? "Minint — Untitled"
public void SelectDocument(MinintDocument? doc) : "Minint";
{
_suppressDocumentSync = true; public ObservableCollection<MinintDocument> Documents { get; } = [];
ActiveDocument = doc; public ObservableCollection<MinintLayer> Layers { get; } = [];
_suppressDocumentSync = false;
#region Container / Document management
SyncLayersAndCanvas(doc);
} public void NewContainer(int width, int height)
{
private void SyncLayersAndCanvas(MinintDocument? doc) var c = new MinintContainer(width, height);
{ c.AddNewDocument("Document 1");
Layers.Clear(); LoadContainer(c, null);
if (doc is not null) }
{
foreach (var layer in doc.Layers) public void LoadContainer(MinintContainer container, string? path)
Layers.Add(layer); {
ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null; Container = container;
} FilePath = path;
else
{ Documents.Clear();
ActiveLayer = null; foreach (var doc in container.Documents)
} Documents.Add(doc);
RefreshCanvas(); SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
} }
public void RefreshCanvas() partial void OnActiveDocumentChanged(MinintDocument? value)
{ {
if (Container is null || ActiveDocument is null) if (_suppressDocumentSync) return;
{ SyncLayersAndCanvas(value);
CanvasBitmap = null; }
return;
} public void SelectDocument(MinintDocument? doc)
{
int w = Container.Width; _suppressDocumentSync = true;
int h = Container.Height; ActiveDocument = doc;
uint[] argb = _compositor.Composite(ActiveDocument, w, h); _suppressDocumentSync = false;
SyncLayersAndCanvas(doc);
var bmp = new WriteableBitmap( }
new PixelSize(w, h),
new Vector(96, 96), private void SyncLayersAndCanvas(MinintDocument? doc)
Avalonia.Platform.PixelFormat.Bgra8888); {
Layers.Clear();
using (var fb = bmp.Lock()) if (doc is not null)
{ {
unsafe foreach (var layer in doc.Layers)
{ Layers.Add(layer);
var dst = new Span<uint>((void*)fb.Address, w * h); ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
for (int i = 0; i < argb.Length; i++) }
{ else
// argb[i] is 0xAARRGGBB, need premultiplied BGRA for the bitmap {
uint px = argb[i]; ActiveLayer = null;
byte a = (byte)(px >> 24); }
byte r = (byte)((px >> 16) & 0xFF); RefreshCanvas();
byte g = (byte)((px >> 8) & 0xFF); }
byte b = (byte)(px & 0xFF);
#endregion
if (a == 255)
{ #region Drawing
dst[i] = px; // ARGB layout == BGRA in LE memory, alpha=255 → no premul needed
} /// <summary>
else if (a == 0) /// Called by PixelCanvas on left-click at the given image pixel coordinate.
{ /// </summary>
dst[i] = 0; public void OnToolDown(int px, int py)
} {
else if (Container is null || ActiveDocument is null || ActiveLayer is null)
{ return;
r = (byte)(r * a / 255);
g = (byte)(g * a / 255); int w = Container.Width, h = Container.Height;
b = (byte)(b * a / 255); if (px < 0 || px >= w || py < 0 || py >= h)
dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24)); return;
}
} switch (ActiveTool)
} {
} case ToolType.Brush:
{
CanvasBitmap = bmp; int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
} _drawingService.ApplyBrush(ActiveLayer, px, py, BrushRadius, colorIdx, w, h);
break;
public ICompositor Compositor => _compositor; }
public IPaletteService PaletteService => _paletteService; 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;
}

View File

@@ -0,0 +1,8 @@
namespace Minint.ViewModels;
public enum ToolType
{
Brush,
Eraser,
Fill
}

View 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;
}
}

View File

@@ -1,95 +1,129 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Minint.ViewModels" xmlns:vm="using:Minint.ViewModels"
xmlns:controls="using:Minint.Controls" xmlns:controls="using:Minint.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="700" mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="700"
x:Class="Minint.Views.MainWindow" x:Class="Minint.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico" Icon="/Assets/avalonia-logo.ico"
Title="{Binding Editor.Title}" Title="{Binding Editor.Title}"
Width="1024" Height="700"> Width="1024" Height="700">
<Design.DataContext> <Design.DataContext>
<vm:MainWindowViewModel/> <vm:MainWindowViewModel/>
</Design.DataContext> </Design.DataContext>
<DockPanel> <DockPanel>
<!-- Menu bar --> <!-- Menu bar -->
<Menu DockPanel.Dock="Top"> <Menu DockPanel.Dock="Top">
<MenuItem Header="_File"> <MenuItem Header="_File">
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/> <MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/> <MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
<Separator/> <Separator/>
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/> <MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/> <MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
</MenuItem> </MenuItem>
<MenuItem Header="_View"> <MenuItem Header="_View">
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox" <MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/> IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
</MenuItem> </MenuItem>
</Menu> </Menu>
<!-- Status bar --> <!-- Toolbar -->
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}" <Border DockPanel.Dock="Top"
Padding="8,2"> BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
<TextBlock Text="{Binding StatusText}" FontSize="12"/> BorderThickness="0,0,0,1" Padding="6,4">
</Border> <StackPanel Orientation="Horizontal" Spacing="10">
<RadioButton GroupName="Tool" Content="Brush"
<!-- Main content: left panel, canvas, right panel --> IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsBrush}}"
<Grid ColumnDefinitions="180,*,180"> Command="{Binding Editor.SelectBrushCommand}"/>
<RadioButton GroupName="Tool" Content="Eraser"
<!-- Left panel: documents --> IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsEraser}}"
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" Command="{Binding Editor.SelectEraserCommand}"/>
BorderThickness="0,0,1,0" Padding="4"> <RadioButton GroupName="Tool" Content="Fill"
<DockPanel> IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/> Command="{Binding Editor.SelectFillCommand}"/>
<ListBox ItemsSource="{Binding Editor.Documents}"
SelectedItem="{Binding Editor.ActiveDocument}" <Separator/>
SelectionMode="Single">
<ListBox.ItemTemplate> <TextBlock Text="Size:" VerticalAlignment="Center"/>
<DataTemplate> <Slider Value="{Binding Editor.BrushRadius}" Minimum="0" Maximum="64"
<TextBlock Text="{Binding Name}"/> TickFrequency="1" IsSnapToTickEnabled="True" Width="120"
</DataTemplate> VerticalAlignment="Center"/>
</ListBox.ItemTemplate> <TextBlock Text="{Binding Editor.BrushRadius}" VerticalAlignment="Center"
</ListBox> Width="20" TextAlignment="Center"/>
</DockPanel>
</Border> <Separator/>
<!-- Center: canvas with scrollbars --> <TextBlock Text="Color:" VerticalAlignment="Center"/>
<Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto"> <ColorPicker x:Name="ToolColorPicker"
<Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True"> Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
<controls:PixelCanvas x:Name="Canvas" IsAlphaVisible="False"
SourceBitmap="{Binding Editor.CanvasBitmap}" VerticalAlignment="Center"/>
ShowGrid="{Binding Editor.ShowGrid}"/> </StackPanel>
</Border> </Border>
<ScrollBar x:Name="HScroll" Grid.Row="1" Grid.Column="0"
Orientation="Horizontal"/> <!-- Status bar -->
<ScrollBar x:Name="VScroll" Grid.Row="0" Grid.Column="1" <Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
Orientation="Vertical"/> Padding="8,2">
</Grid> <TextBlock Text="{Binding StatusText}" FontSize="12"/>
</Border>
<!-- Right panel: layers -->
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" <!-- Main content: left panel, canvas, right panel -->
BorderThickness="1,0,0,0" Padding="4"> <Grid ColumnDefinitions="180,*,180">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/> <!-- Left panel: documents -->
<ListBox ItemsSource="{Binding Editor.Layers}" <Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
SelectedItem="{Binding Editor.ActiveLayer}" BorderThickness="0,0,1,0" Padding="4">
SelectionMode="Single"> <DockPanel>
<ListBox.ItemTemplate> <TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
<DataTemplate> <ListBox ItemsSource="{Binding Editor.Documents}"
<StackPanel Orientation="Horizontal" Spacing="4"> SelectedItem="{Binding Editor.ActiveDocument}"
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"/> SelectionMode="Single">
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/> <ListBox.ItemTemplate>
</StackPanel> <DataTemplate>
</DataTemplate> <TextBlock Text="{Binding Name}"/>
</ListBox.ItemTemplate> </DataTemplate>
</ListBox> </ListBox.ItemTemplate>
</DockPanel> </ListBox>
</Border> </DockPanel>
</Border>
</Grid>
</DockPanel> <!-- Center: canvas with scrollbars -->
</Window> <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>

View File

@@ -1,34 +1,45 @@
using System; using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Minint.Controls; using Minint.Controls;
using Minint.ViewModels; using Minint.ViewModels;
namespace Minint.Views; namespace Minint.Views;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnOpened(EventArgs e) protected override void OnOpened(EventArgs e)
{ {
base.OnOpened(e); base.OnOpened(e);
var canvas = this.FindControl<PixelCanvas>("Canvas"); var canvas = this.FindControl<PixelCanvas>("Canvas");
var hScroll = this.FindControl<ScrollBar>("HScroll"); var hScroll = this.FindControl<ScrollBar>("HScroll");
var vScroll = this.FindControl<ScrollBar>("VScroll"); var vScroll = this.FindControl<ScrollBar>("VScroll");
if (canvas is not null && hScroll is not null && vScroll is not null) if (canvas is not null && hScroll is not null && vScroll is not null)
canvas.AttachScrollBars(hScroll, vScroll); canvas.AttachScrollBars(hScroll, vScroll);
}
if (canvas is not null && DataContext is MainWindowViewModel vm)
protected override void OnDataContextChanged(EventArgs e) WireCanvasEvents(canvas, vm.Editor);
{ }
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel vm) protected override void OnDataContextChanged(EventArgs e)
vm.Owner = this; {
} 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();
}
}