Compare commits
3 Commits
9ef5ad8f68
...
3a61e0a07d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a61e0a07d | |||
| c3961fcba7 | |||
| 25e30416a3 |
@@ -1,19 +1,39 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace Minint.Core.Models;
|
namespace Minint.Core.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single raster layer. Pixels are indices into the parent document's palette.
|
/// A single raster layer. Pixels are indices into the parent document's palette.
|
||||||
/// Array layout is row-major: Pixels[y * width + x].
|
/// Array layout is row-major: Pixels[y * width + x].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MinintLayer
|
public sealed class MinintLayer : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
private string _name;
|
||||||
public bool IsVisible { get; set; }
|
private bool _isVisible;
|
||||||
|
private byte _opacity;
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => _name;
|
||||||
|
set { if (_name != value) { _name = value; Notify(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsVisible
|
||||||
|
{
|
||||||
|
get => _isVisible;
|
||||||
|
set { if (_isVisible != value) { _isVisible = value; Notify(); } }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-layer opacity (0 = fully transparent, 255 = fully opaque).
|
/// Per-layer opacity (0 = fully transparent, 255 = fully opaque).
|
||||||
/// Used during compositing: effective alpha = paletteColor.A * Opacity / 255.
|
/// Used during compositing: effective alpha = paletteColor.A * Opacity / 255.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte Opacity { get; set; }
|
public byte Opacity
|
||||||
|
{
|
||||||
|
get => _opacity;
|
||||||
|
set { if (_opacity != value) { _opacity = value; Notify(); } }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Palette indices, length must equal container Width * Height.
|
/// Palette indices, length must equal container Width * Height.
|
||||||
@@ -23,9 +43,9 @@ public sealed class MinintLayer
|
|||||||
|
|
||||||
public MinintLayer(string name, int pixelCount)
|
public MinintLayer(string name, int pixelCount)
|
||||||
{
|
{
|
||||||
Name = name;
|
_name = name;
|
||||||
IsVisible = true;
|
_isVisible = true;
|
||||||
Opacity = 255;
|
_opacity = 255;
|
||||||
Pixels = new int[pixelCount];
|
Pixels = new int[pixelCount];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +54,13 @@ public sealed class MinintLayer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels)
|
public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels)
|
||||||
{
|
{
|
||||||
Name = name;
|
_name = name;
|
||||||
IsVisible = isVisible;
|
_isVisible = isVisible;
|
||||||
Opacity = opacity;
|
_opacity = opacity;
|
||||||
Pixels = pixels;
|
Pixels = pixels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void Notify([CallerMemberName] string? prop = null)
|
||||||
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
|
||||||
}
|
}
|
||||||
|
|||||||
18
Minint.Core/Services/IImageEffectsService.cs
Normal file
18
Minint.Core/Services/IImageEffectsService.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Minint.Core.Models;
|
||||||
|
|
||||||
|
namespace Minint.Core.Services;
|
||||||
|
|
||||||
|
public interface IImageEffectsService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adjusts contrast of the document by transforming its palette colors.
|
||||||
|
/// <paramref name="factor"/> of 0 = all gray, 1 = no change, >1 = increased contrast.
|
||||||
|
/// </summary>
|
||||||
|
void ApplyContrast(MinintDocument doc, double factor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the document to grayscale by transforming its palette colors
|
||||||
|
/// using the luminance formula: 0.299R + 0.587G + 0.114B.
|
||||||
|
/// </summary>
|
||||||
|
void ApplyGrayscale(MinintDocument doc);
|
||||||
|
}
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Minint.Core/Services/Impl/FragmentService.cs
Normal file
49
Minint.Core/Services/Impl/FragmentService.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
|
||||||
|
namespace Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
public sealed class FragmentService : IFragmentService
|
||||||
|
{
|
||||||
|
public void CopyFragment(
|
||||||
|
MinintDocument srcDoc, int srcLayerIndex,
|
||||||
|
int srcX, int srcY, int regionWidth, int regionHeight,
|
||||||
|
MinintDocument dstDoc, int dstLayerIndex,
|
||||||
|
int dstX, int dstY,
|
||||||
|
int containerWidth, int containerHeight)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(srcLayerIndex);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(dstLayerIndex);
|
||||||
|
if (srcLayerIndex >= srcDoc.Layers.Count)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(srcLayerIndex));
|
||||||
|
if (dstLayerIndex >= dstDoc.Layers.Count)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(dstLayerIndex));
|
||||||
|
|
||||||
|
var srcLayer = srcDoc.Layers[srcLayerIndex];
|
||||||
|
var dstLayer = dstDoc.Layers[dstLayerIndex];
|
||||||
|
|
||||||
|
int clippedSrcX = Math.Max(srcX, 0);
|
||||||
|
int clippedSrcY = Math.Max(srcY, 0);
|
||||||
|
int clippedEndX = Math.Min(srcX + regionWidth, containerWidth);
|
||||||
|
int clippedEndY = Math.Min(srcY + regionHeight, containerHeight);
|
||||||
|
|
||||||
|
for (int sy = clippedSrcY; sy < clippedEndY; sy++)
|
||||||
|
{
|
||||||
|
int dy = dstY + (sy - srcY);
|
||||||
|
if (dy < 0 || dy >= containerHeight) continue;
|
||||||
|
|
||||||
|
for (int sx = clippedSrcX; sx < clippedEndX; sx++)
|
||||||
|
{
|
||||||
|
int dx = dstX + (sx - srcX);
|
||||||
|
if (dx < 0 || dx >= containerWidth) continue;
|
||||||
|
|
||||||
|
int srcIdx = srcLayer.Pixels[sy * containerWidth + sx];
|
||||||
|
if (srcIdx == 0) continue; // skip transparent
|
||||||
|
|
||||||
|
RgbaColor color = srcDoc.Palette[srcIdx];
|
||||||
|
int dstIdx = dstDoc.EnsureColorCached(color);
|
||||||
|
dstLayer.Pixels[dy * containerWidth + dx] = dstIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Minint.Core/Services/Impl/ImageEffectsService.cs
Normal file
38
Minint.Core/Services/Impl/ImageEffectsService.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
|
||||||
|
namespace Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
public sealed class ImageEffectsService : IImageEffectsService
|
||||||
|
{
|
||||||
|
public void ApplyContrast(MinintDocument doc, double factor)
|
||||||
|
{
|
||||||
|
for (int i = 1; i < doc.Palette.Count; i++)
|
||||||
|
{
|
||||||
|
var c = doc.Palette[i];
|
||||||
|
doc.Palette[i] = new RgbaColor(
|
||||||
|
ContrastByte(c.R, factor),
|
||||||
|
ContrastByte(c.G, factor),
|
||||||
|
ContrastByte(c.B, factor),
|
||||||
|
c.A);
|
||||||
|
}
|
||||||
|
doc.InvalidatePaletteCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyGrayscale(MinintDocument doc)
|
||||||
|
{
|
||||||
|
for (int i = 1; i < doc.Palette.Count; i++)
|
||||||
|
{
|
||||||
|
var c = doc.Palette[i];
|
||||||
|
byte gray = (byte)Math.Clamp((int)(0.299 * c.R + 0.587 * c.G + 0.114 * c.B + 0.5), 0, 255);
|
||||||
|
doc.Palette[i] = new RgbaColor(gray, gray, gray, c.A);
|
||||||
|
}
|
||||||
|
doc.InvalidatePaletteCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte ContrastByte(byte value, double factor)
|
||||||
|
{
|
||||||
|
double v = ((value / 255.0) - 0.5) * factor + 0.5;
|
||||||
|
return (byte)Math.Clamp((int)(v * 255 + 0.5), 0, 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
Minint.Core/Services/Impl/PatternGenerator.cs
Normal file
113
Minint.Core/Services/Impl/PatternGenerator.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
|
||||||
|
namespace Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
public sealed class PatternGenerator : IPatternGenerator
|
||||||
|
{
|
||||||
|
public MinintDocument Generate(PatternType type, int width, int height, RgbaColor[] colors, int param1, int param2 = 0)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
|
||||||
|
if (colors.Length < 2)
|
||||||
|
throw new ArgumentException("At least two colors are required.", nameof(colors));
|
||||||
|
|
||||||
|
var doc = new MinintDocument($"Pattern ({type})");
|
||||||
|
var layer = new MinintLayer("Pattern", width * height);
|
||||||
|
doc.Layers.Add(layer);
|
||||||
|
|
||||||
|
int[] colorIndices = new int[colors.Length];
|
||||||
|
for (int i = 0; i < colors.Length; i++)
|
||||||
|
colorIndices[i] = doc.EnsureColorCached(colors[i]);
|
||||||
|
|
||||||
|
int cellSize = Math.Max(param1, 1);
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case PatternType.Checkerboard:
|
||||||
|
FillCheckerboard(layer.Pixels, width, height, colorIndices, cellSize);
|
||||||
|
break;
|
||||||
|
case PatternType.HorizontalGradient:
|
||||||
|
FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: true);
|
||||||
|
break;
|
||||||
|
case PatternType.VerticalGradient:
|
||||||
|
FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: false);
|
||||||
|
break;
|
||||||
|
case PatternType.HorizontalStripes:
|
||||||
|
FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: true);
|
||||||
|
break;
|
||||||
|
case PatternType.VerticalStripes:
|
||||||
|
FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: false);
|
||||||
|
break;
|
||||||
|
case PatternType.ConcentricCircles:
|
||||||
|
FillCircles(layer.Pixels, width, height, colorIndices, cellSize);
|
||||||
|
break;
|
||||||
|
case PatternType.Tile:
|
||||||
|
FillTile(layer.Pixels, width, height, colorIndices, cellSize, Math.Max(param2, 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillCheckerboard(int[] pixels, int w, int h, int[] ci, int cell)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = 0; x < w; x++)
|
||||||
|
pixels[y * w + x] = ci[((x / cell) + (y / cell)) % 2 == 0 ? 0 : 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillGradient(int[] pixels, int w, int h, RgbaColor c0, RgbaColor c1,
|
||||||
|
MinintDocument doc, bool horizontal)
|
||||||
|
{
|
||||||
|
int steps = horizontal ? w : h;
|
||||||
|
for (int s = 0; s < steps; s++)
|
||||||
|
{
|
||||||
|
double t = steps > 1 ? (double)s / (steps - 1) : 0;
|
||||||
|
var c = new RgbaColor(
|
||||||
|
Lerp(c0.R, c1.R, t), Lerp(c0.G, c1.G, t),
|
||||||
|
Lerp(c0.B, c1.B, t), Lerp(c0.A, c1.A, t));
|
||||||
|
int idx = doc.EnsureColorCached(c);
|
||||||
|
if (horizontal)
|
||||||
|
for (int y = 0; y < h; y++) pixels[y * w + s] = idx;
|
||||||
|
else
|
||||||
|
for (int x = 0; x < w; x++) pixels[s * w + x] = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillStripes(int[] pixels, int w, int h, int[] ci, int stripe, bool horizontal)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = 0; x < w; x++)
|
||||||
|
{
|
||||||
|
int coord = horizontal ? y : x;
|
||||||
|
pixels[y * w + x] = ci[(coord / stripe) % ci.Length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillCircles(int[] pixels, int w, int h, int[] ci, int ringWidth)
|
||||||
|
{
|
||||||
|
double cx = (w - 1) / 2.0, cy = (h - 1) / 2.0;
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = 0; x < w; x++)
|
||||||
|
{
|
||||||
|
double dist = Math.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
|
||||||
|
int ring = (int)(dist / ringWidth);
|
||||||
|
pixels[y * w + x] = ci[ring % ci.Length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillTile(int[] pixels, int w, int h, int[] ci, int tileW, int tileH)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = 0; x < w; x++)
|
||||||
|
{
|
||||||
|
int tx = (x / tileW) % ci.Length;
|
||||||
|
int ty = (y / tileH) % ci.Length;
|
||||||
|
pixels[y * w + x] = ci[(tx + ty) % ci.Length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte Lerp(byte a, byte b, double t)
|
||||||
|
=> (byte)Math.Clamp((int)(a + (b - a) * t + 0.5), 0, 255);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
129
Minint/Controls/EditableTextBlock.cs
Normal file
129
Minint/Controls/EditableTextBlock.cs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace Minint.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a TextBlock by default; switches to an inline TextBox on double-click.
|
||||||
|
/// Commits on Enter or focus loss, cancels on Escape.
|
||||||
|
/// </summary>
|
||||||
|
public class EditableTextBlock : Control
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<string> TextProperty =
|
||||||
|
AvaloniaProperty.Register<EditableTextBlock, string>(nameof(Text), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
|
||||||
|
|
||||||
|
public string Text
|
||||||
|
{
|
||||||
|
get => GetValue(TextProperty);
|
||||||
|
set => SetValue(TextProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly TextBlock _display;
|
||||||
|
private readonly TextBox _editor;
|
||||||
|
private bool _isEditing;
|
||||||
|
|
||||||
|
public EditableTextBlock()
|
||||||
|
{
|
||||||
|
_display = new TextBlock
|
||||||
|
{
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
};
|
||||||
|
|
||||||
|
_editor = new TextBox
|
||||||
|
{
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Padding = new Thickness(2, 0),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
MinWidth = 40,
|
||||||
|
IsVisible = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
LogicalChildren.Add(_display);
|
||||||
|
LogicalChildren.Add(_editor);
|
||||||
|
VisualChildren.Add(_display);
|
||||||
|
VisualChildren.Add(_editor);
|
||||||
|
|
||||||
|
_display.Bind(TextBlock.TextProperty, this.GetObservable(TextProperty).ToBinding());
|
||||||
|
_editor.Bind(TextBox.TextProperty, this.GetObservable(TextProperty).ToBinding());
|
||||||
|
|
||||||
|
_editor.KeyDown += OnEditorKeyDown;
|
||||||
|
_editor.LostFocus += OnEditorLostFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerPressed(e);
|
||||||
|
if (e.ClickCount == 2 && !_isEditing)
|
||||||
|
{
|
||||||
|
BeginEdit();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginEdit()
|
||||||
|
{
|
||||||
|
_isEditing = true;
|
||||||
|
_editor.Text = Text;
|
||||||
|
_display.IsVisible = false;
|
||||||
|
_editor.IsVisible = true;
|
||||||
|
_editor.Focus();
|
||||||
|
_editor.SelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CommitEdit()
|
||||||
|
{
|
||||||
|
if (!_isEditing) return;
|
||||||
|
_isEditing = false;
|
||||||
|
Text = _editor.Text ?? string.Empty;
|
||||||
|
_editor.IsVisible = false;
|
||||||
|
_display.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEdit()
|
||||||
|
{
|
||||||
|
if (!_isEditing) return;
|
||||||
|
_isEditing = false;
|
||||||
|
_editor.Text = Text;
|
||||||
|
_editor.IsVisible = false;
|
||||||
|
_display.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEditorKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
CommitEdit();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
CancelEdit();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEditorLostFocus(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
CommitEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
_display.Measure(availableSize);
|
||||||
|
_editor.Measure(availableSize);
|
||||||
|
return _isEditing ? _editor.DesiredSize : _display.DesiredSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size ArrangeOverride(Size finalSize)
|
||||||
|
{
|
||||||
|
var rect = new Rect(finalSize);
|
||||||
|
_display.Arrange(rect);
|
||||||
|
_editor.Arrange(rect);
|
||||||
|
return finalSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,325 +1,437 @@
|
|||||||
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 _lastBitmapWidth;
|
||||||
public Viewport Viewport => _viewport;
|
private int _lastBitmapHeight;
|
||||||
|
private (int X, int Y)? _lastCursorPixel;
|
||||||
static PixelCanvas()
|
private Point? _lastScreenPos;
|
||||||
{
|
|
||||||
AffectsRender<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
|
private ScrollBar? _hScrollBar;
|
||||||
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true);
|
private ScrollBar? _vScrollBar;
|
||||||
}
|
private bool _suppressScrollSync;
|
||||||
|
|
||||||
public PixelCanvas()
|
private const double ScrollPixelsPerTick = 20.0;
|
||||||
{
|
|
||||||
ClipToBounds = true;
|
public Viewport Viewport => _viewport;
|
||||||
}
|
|
||||||
|
static PixelCanvas()
|
||||||
/// <summary>
|
{
|
||||||
/// Connects external ScrollBar controls. Call once after the UI is built.
|
AffectsRender<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
|
||||||
/// </summary>
|
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true);
|
||||||
public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
|
}
|
||||||
{
|
|
||||||
if (_hScrollBar is not null)
|
public PixelCanvas()
|
||||||
_hScrollBar.ValueChanged -= OnHScrollChanged;
|
{
|
||||||
if (_vScrollBar is not null)
|
ClipToBounds = true;
|
||||||
_vScrollBar.ValueChanged -= OnVScrollChanged;
|
}
|
||||||
|
|
||||||
_hScrollBar = horizontal;
|
public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
|
||||||
_vScrollBar = vertical;
|
{
|
||||||
|
if (_hScrollBar is not null) _hScrollBar.ValueChanged -= OnHScrollChanged;
|
||||||
_hScrollBar.ValueChanged += OnHScrollChanged;
|
if (_vScrollBar is not null) _vScrollBar.ValueChanged -= OnVScrollChanged;
|
||||||
_vScrollBar.ValueChanged += OnVScrollChanged;
|
_hScrollBar = horizontal;
|
||||||
}
|
_vScrollBar = vertical;
|
||||||
|
_hScrollBar.ValueChanged += OnHScrollChanged;
|
||||||
#region Rendering
|
_vScrollBar.ValueChanged += OnVScrollChanged;
|
||||||
|
}
|
||||||
public override void Render(DrawingContext context)
|
|
||||||
{
|
#region Rendering
|
||||||
base.Render(context);
|
|
||||||
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
public override void Render(DrawingContext context)
|
||||||
|
{
|
||||||
var bmp = SourceBitmap;
|
base.Render(context);
|
||||||
if (bmp is null)
|
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
||||||
return;
|
|
||||||
|
var bmp = SourceBitmap;
|
||||||
int imgW = bmp.PixelSize.Width;
|
if (bmp is null) return;
|
||||||
int imgH = bmp.PixelSize.Height;
|
|
||||||
|
int imgW = bmp.PixelSize.Width;
|
||||||
if (!_viewportInitialized)
|
int imgH = bmp.PixelSize.Height;
|
||||||
{
|
|
||||||
_viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
|
if (!_viewportInitialized)
|
||||||
_viewportInitialized = true;
|
{
|
||||||
}
|
_viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
_viewportInitialized = true;
|
||||||
DrawCheckerboard(context, imgW, imgH);
|
}
|
||||||
|
|
||||||
var destRect = _viewport.ImageScreenRect(imgW, imgH);
|
DrawCheckerboard(context, imgW, imgH);
|
||||||
var srcRect = new Rect(0, 0, imgW, imgH);
|
|
||||||
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
|
var destRect = _viewport.ImageScreenRect(imgW, imgH);
|
||||||
context.DrawImage(bmp, srcRect, destRect);
|
var srcRect = new Rect(0, 0, imgW, imgH);
|
||||||
|
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
|
||||||
if (ShowGrid && _viewport.Zoom >= 4)
|
context.DrawImage(bmp, srcRect, destRect);
|
||||||
DrawPixelGrid(context, imgW, imgH);
|
|
||||||
|
if (ShowGrid && _viewport.Zoom >= 4)
|
||||||
// Defer scrollbar sync — updating layout properties during Render is forbidden
|
DrawPixelGrid(context, imgW, imgH);
|
||||||
int w = imgW, h = imgH;
|
|
||||||
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
|
DrawToolPreview(context, imgW, imgH);
|
||||||
}
|
|
||||||
|
int w = imgW, h = imgH;
|
||||||
private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
|
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
|
||||||
{
|
}
|
||||||
var rect = _viewport.ImageScreenRect(imgW, imgH);
|
|
||||||
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
|
||||||
var visible = rect.Intersect(clip);
|
{
|
||||||
if (visible.Width <= 0 || visible.Height <= 0) return;
|
var rect = _viewport.ImageScreenRect(imgW, imgH);
|
||||||
|
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||||
const int checkerSize = 8;
|
var visible = rect.Intersect(clip);
|
||||||
var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
|
if (visible.Width <= 0 || visible.Height <= 0) return;
|
||||||
var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
|
|
||||||
|
const int checkerSize = 8;
|
||||||
using (context.PushClip(visible))
|
var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
|
||||||
{
|
var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
|
||||||
context.FillRectangle(light, visible);
|
|
||||||
|
using (context.PushClip(visible))
|
||||||
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
|
{
|
||||||
double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
|
context.FillRectangle(light, visible);
|
||||||
|
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
|
||||||
for (double y = startY; y < visible.Bottom; y += checkerSize)
|
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)
|
{
|
||||||
{
|
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++)
|
for (int py = startPy; py <= endPy; py++)
|
||||||
{
|
{
|
||||||
var (_, sy) = _viewport.PixelToScreen(0, py);
|
var (_, sy) = _viewport.PixelToScreen(0, py);
|
||||||
context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
|
context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
private void DrawToolPreview(DrawingContext context, int imgW, int imgH)
|
||||||
|
{
|
||||||
#region Scrollbar Sync
|
var mask = GetPreviewMask?.Invoke();
|
||||||
|
if (mask is null || mask.Count == 0) return;
|
||||||
private void SyncScrollBars(int imgW, int imgH)
|
|
||||||
{
|
double zoom = _viewport.Zoom;
|
||||||
if (_hScrollBar is null || _vScrollBar is null) return;
|
var previewBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255));
|
||||||
|
var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), 1);
|
||||||
_suppressScrollSync = true;
|
|
||||||
|
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||||
// Scrollbar value is negated offset: increasing value = scroll right = offset decreases
|
using (context.PushClip(clip))
|
||||||
var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
|
{
|
||||||
_hScrollBar.Minimum = -hMax;
|
foreach (var (px, py) in mask)
|
||||||
_hScrollBar.Maximum = -hMin;
|
{
|
||||||
_hScrollBar.Value = -hVal;
|
var (sx, sy) = _viewport.PixelToScreen(px, py);
|
||||||
_hScrollBar.ViewportSize = hView;
|
var r = new Rect(sx, sy, zoom, zoom);
|
||||||
|
context.FillRectangle(previewBrush, r);
|
||||||
var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
|
}
|
||||||
_vScrollBar.Minimum = -vMax;
|
|
||||||
_vScrollBar.Maximum = -vMin;
|
// Outline around the mask bounding box
|
||||||
_vScrollBar.Value = -vVal;
|
if (mask.Count > 0)
|
||||||
_vScrollBar.ViewportSize = vView;
|
{
|
||||||
|
int minX = mask[0].X, maxX = mask[0].X;
|
||||||
_suppressScrollSync = false;
|
int minY = mask[0].Y, maxY = mask[0].Y;
|
||||||
}
|
foreach (var (px, py) in mask)
|
||||||
|
{
|
||||||
private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
if (px < minX) minX = px;
|
||||||
{
|
if (px > maxX) maxX = px;
|
||||||
if (_suppressScrollSync) return;
|
if (py < minY) minY = py;
|
||||||
var (imgW, imgH) = GetImageSize();
|
if (py > maxY) maxY = py;
|
||||||
_viewport.SetOffset(-e.NewValue, _viewport.OffsetY,
|
}
|
||||||
imgW, imgH, Bounds.Width, Bounds.Height);
|
var (ox, oy) = _viewport.PixelToScreen(minX, minY);
|
||||||
InvalidateVisual();
|
var outlineRect = new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom);
|
||||||
}
|
context.DrawRectangle(outlinePen, outlineRect);
|
||||||
|
}
|
||||||
private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
}
|
||||||
{
|
}
|
||||||
if (_suppressScrollSync) return;
|
|
||||||
var (imgW, imgH) = GetImageSize();
|
#endregion
|
||||||
_viewport.SetOffset(_viewport.OffsetX, -e.NewValue,
|
|
||||||
imgW, imgH, Bounds.Width, Bounds.Height);
|
#region Scrollbar Sync
|
||||||
InvalidateVisual();
|
|
||||||
}
|
private void SyncScrollBars(int imgW, int imgH)
|
||||||
|
{
|
||||||
#endregion
|
if (_hScrollBar is null || _vScrollBar is null) return;
|
||||||
|
_suppressScrollSync = true;
|
||||||
#region Mouse Input
|
|
||||||
|
var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
|
||||||
private (int W, int H) GetImageSize()
|
_hScrollBar.Minimum = -hMax;
|
||||||
{
|
_hScrollBar.Maximum = -hMin;
|
||||||
var bmp = SourceBitmap;
|
_hScrollBar.Value = -hVal;
|
||||||
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
|
_hScrollBar.ViewportSize = hView;
|
||||||
}
|
|
||||||
|
var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
|
||||||
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
_vScrollBar.Minimum = -vMax;
|
||||||
{
|
_vScrollBar.Maximum = -vMin;
|
||||||
base.OnPointerWheelChanged(e);
|
_vScrollBar.Value = -vVal;
|
||||||
var (imgW, imgH) = GetImageSize();
|
_vScrollBar.ViewportSize = vView;
|
||||||
if (imgW == 0) return;
|
|
||||||
|
_suppressScrollSync = false;
|
||||||
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
|
}
|
||||||
bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
|
|
||||||
|
private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
if (ctrl)
|
{
|
||||||
{
|
if (_suppressScrollSync) return;
|
||||||
var pos = e.GetPosition(this);
|
var (imgW, imgH) = GetImageSize();
|
||||||
_viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y,
|
_viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
imgW, imgH, Bounds.Width, Bounds.Height);
|
RecalcCursorPixel();
|
||||||
}
|
InvalidateVisual();
|
||||||
else
|
}
|
||||||
{
|
|
||||||
double dx = e.Delta.X * ScrollPixelsPerTick;
|
private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
double dy = e.Delta.Y * ScrollPixelsPerTick;
|
{
|
||||||
|
if (_suppressScrollSync) return;
|
||||||
if (shift && Math.Abs(e.Delta.X) < 0.001)
|
var (imgW, imgH) = GetImageSize();
|
||||||
{
|
_viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
dx = dy;
|
RecalcCursorPixel();
|
||||||
dy = 0;
|
InvalidateVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
_viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
|
#endregion
|
||||||
}
|
|
||||||
|
#region Mouse Input
|
||||||
InvalidateVisual();
|
|
||||||
e.Handled = true;
|
private (int W, int H) GetImageSize()
|
||||||
}
|
{
|
||||||
|
var bmp = SourceBitmap;
|
||||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
|
||||||
{
|
}
|
||||||
base.OnPointerPressed(e);
|
|
||||||
|
private (int X, int Y)? ScreenToPixelClamped(Point pos)
|
||||||
if (e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed)
|
{
|
||||||
{
|
var (imgW, imgH) = GetImageSize();
|
||||||
_isPanning = true;
|
if (imgW == 0) return null;
|
||||||
_panStart = e.GetPosition(this);
|
var (px, py) = _viewport.ScreenToPixel(pos.X, pos.Y);
|
||||||
_panStartOffsetX = _viewport.OffsetX;
|
if (px < 0 || px >= imgW || py < 0 || py >= imgH)
|
||||||
_panStartOffsetY = _viewport.OffsetY;
|
return null;
|
||||||
e.Handled = true;
|
return (px, py);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
protected override void OnPointerMoved(PointerEventArgs e)
|
/// Recalculates the pixel coordinate under the cursor after a viewport change.
|
||||||
{
|
/// </summary>
|
||||||
base.OnPointerMoved(e);
|
private void RecalcCursorPixel()
|
||||||
|
{
|
||||||
if (_isPanning)
|
if (_lastScreenPos is null) return;
|
||||||
{
|
var pixel = ScreenToPixelClamped(_lastScreenPos.Value);
|
||||||
var pos = e.GetPosition(this);
|
if (pixel != _lastCursorPixel)
|
||||||
var (imgW, imgH) = GetImageSize();
|
{
|
||||||
_viewport.SetOffset(
|
_lastCursorPixel = pixel;
|
||||||
_panStartOffsetX + (pos.X - _panStart.X),
|
CursorPixelChanged?.Invoke(pixel);
|
||||||
_panStartOffsetY + (pos.Y - _panStart.Y),
|
}
|
||||||
imgW, imgH, Bounds.Width, Bounds.Height);
|
}
|
||||||
InvalidateVisual();
|
|
||||||
e.Handled = true;
|
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||||
}
|
{
|
||||||
}
|
base.OnPointerWheelChanged(e);
|
||||||
|
var (imgW, imgH) = GetImageSize();
|
||||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
if (imgW == 0) return;
|
||||||
{
|
|
||||||
base.OnPointerReleased(e);
|
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
|
||||||
|
bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
|
||||||
if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle)
|
|
||||||
{
|
if (ctrl)
|
||||||
_isPanning = false;
|
{
|
||||||
e.Handled = true;
|
var pos = e.GetPosition(this);
|
||||||
}
|
_viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
#endregion
|
{
|
||||||
|
double dx = e.Delta.X * ScrollPixelsPerTick;
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
double dy = e.Delta.Y * ScrollPixelsPerTick;
|
||||||
{
|
if (shift && Math.Abs(e.Delta.X) < 0.001)
|
||||||
base.OnPropertyChanged(change);
|
{
|
||||||
|
dx = dy;
|
||||||
if (change.Property == SourceBitmapProperty)
|
dy = 0;
|
||||||
_viewportInitialized = false;
|
}
|
||||||
}
|
_viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecalcCursorPixel();
|
||||||
|
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);
|
||||||
|
_lastScreenPos = pos;
|
||||||
|
|
||||||
|
if (_isPanning)
|
||||||
|
{
|
||||||
|
var (imgW, imgH) = GetImageSize();
|
||||||
|
_viewport.SetOffset(
|
||||||
|
_panStartOffsetX + (pos.X - _panStart.X),
|
||||||
|
_panStartOffsetY + (pos.Y - _panStart.Y),
|
||||||
|
imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
RecalcCursorPixel();
|
||||||
|
InvalidateVisual();
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
_lastScreenPos = null;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var bmp = change.GetNewValue<WriteableBitmap?>();
|
||||||
|
int w = bmp?.PixelSize.Width ?? 0;
|
||||||
|
int h = bmp?.PixelSize.Height ?? 0;
|
||||||
|
if (w != _lastBitmapWidth || h != _lastBitmapHeight)
|
||||||
|
{
|
||||||
|
_lastBitmapWidth = w;
|
||||||
|
_lastBitmapHeight = h;
|
||||||
|
_viewportInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ sealed class Program
|
|||||||
=> AppBuilder.Configure<App>()
|
=> AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace()
|
||||||
|
.With(new X11PlatformOptions { OverlayPopups = true });
|
||||||
|
|
||||||
// TODO: temporary tests — remove after verification stages.
|
// TODO: temporary tests — remove after verification stages.
|
||||||
|
|
||||||
|
|||||||
@@ -1,163 +1,464 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.Generic;
|
||||||
using Avalonia;
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia.Media.Imaging;
|
using System.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using System.Linq;
|
||||||
using Minint.Core.Models;
|
using Avalonia;
|
||||||
using Minint.Core.Services;
|
using Avalonia.Media.Imaging;
|
||||||
using Minint.Core.Services.Impl;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
namespace Minint.ViewModels;
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services;
|
||||||
public partial class EditorViewModel : ViewModelBase
|
using Minint.Core.Services.Impl;
|
||||||
{
|
|
||||||
private readonly ICompositor _compositor = new Compositor();
|
namespace Minint.ViewModels;
|
||||||
private readonly IPaletteService _paletteService = new PaletteService();
|
|
||||||
|
public partial class EditorViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
{
|
||||||
[NotifyPropertyChangedFor(nameof(HasContainer))]
|
private readonly ICompositor _compositor = new Compositor();
|
||||||
[NotifyPropertyChangedFor(nameof(Title))]
|
private readonly IPaletteService _paletteService = new PaletteService();
|
||||||
private MinintContainer? _container;
|
private readonly IDrawingService _drawingService = new DrawingService();
|
||||||
|
private readonly IFloodFillService _floodFillService = new FloodFillService();
|
||||||
[ObservableProperty]
|
private readonly IFragmentService _fragmentService = new FragmentService();
|
||||||
private MinintDocument? _activeDocument;
|
|
||||||
|
[ObservableProperty]
|
||||||
[ObservableProperty]
|
[NotifyPropertyChangedFor(nameof(HasContainer))]
|
||||||
private MinintLayer? _activeLayer;
|
[NotifyPropertyChangedFor(nameof(Title))]
|
||||||
|
private MinintContainer? _container;
|
||||||
private bool _suppressDocumentSync;
|
|
||||||
|
[ObservableProperty]
|
||||||
[ObservableProperty]
|
private MinintDocument? _activeDocument;
|
||||||
private WriteableBitmap? _canvasBitmap;
|
|
||||||
|
[ObservableProperty]
|
||||||
[ObservableProperty]
|
private MinintLayer? _activeLayer;
|
||||||
private bool _showGrid;
|
|
||||||
|
private bool _suppressDocumentSync;
|
||||||
/// <summary>
|
|
||||||
/// Path of the currently open file, or null for unsaved new containers.
|
[ObservableProperty]
|
||||||
/// </summary>
|
private WriteableBitmap? _canvasBitmap;
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(Title))]
|
[ObservableProperty]
|
||||||
private string? _filePath;
|
private bool _showGrid;
|
||||||
|
|
||||||
public bool HasContainer => Container is not null;
|
// Tool state
|
||||||
|
[ObservableProperty]
|
||||||
public string Title => FilePath is not null
|
private ToolType _activeTool = ToolType.Brush;
|
||||||
? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
|
|
||||||
: Container is not null
|
[ObservableProperty]
|
||||||
? "Minint — Untitled"
|
private int _brushRadius = 1;
|
||||||
: "Minint";
|
|
||||||
|
[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)
|
|
||||||
{
|
public Avalonia.Media.Color PreviewColor
|
||||||
var c = new MinintContainer(width, height);
|
{
|
||||||
c.AddNewDocument("Document 1");
|
get => _previewColor;
|
||||||
LoadContainer(c, null);
|
set
|
||||||
}
|
{
|
||||||
|
if (_previewColor == value) return;
|
||||||
public void LoadContainer(MinintContainer container, string? path)
|
_previewColor = value;
|
||||||
{
|
OnPropertyChanged();
|
||||||
Container = container;
|
OnPropertyChanged(nameof(SelectedColor));
|
||||||
FilePath = path;
|
}
|
||||||
|
}
|
||||||
Documents.Clear();
|
|
||||||
foreach (var doc in container.Documents)
|
public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
|
||||||
Documents.Add(doc);
|
|
||||||
|
[ObservableProperty]
|
||||||
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
|
[NotifyPropertyChangedFor(nameof(Title))]
|
||||||
}
|
private string? _filePath;
|
||||||
|
|
||||||
/// <summary>
|
public bool HasContainer => Container is not null;
|
||||||
/// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding).
|
|
||||||
/// </summary>
|
public string Title => FilePath is not null
|
||||||
partial void OnActiveDocumentChanged(MinintDocument? value)
|
? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
|
||||||
{
|
: Container is not null
|
||||||
if (_suppressDocumentSync) return;
|
? "Minint — Untitled"
|
||||||
SyncLayersAndCanvas(value);
|
: "Minint";
|
||||||
}
|
|
||||||
|
public ObservableCollection<MinintDocument> Documents { get; } = [];
|
||||||
public void SelectDocument(MinintDocument? doc)
|
public ObservableCollection<MinintLayer> Layers { get; } = [];
|
||||||
{
|
|
||||||
_suppressDocumentSync = true;
|
#region Container / Document management
|
||||||
ActiveDocument = doc;
|
|
||||||
_suppressDocumentSync = false;
|
public void NewContainer(int width, int height)
|
||||||
|
{
|
||||||
SyncLayersAndCanvas(doc);
|
var c = new MinintContainer(width, height);
|
||||||
}
|
c.AddNewDocument("Document 1");
|
||||||
|
LoadContainer(c, null);
|
||||||
private void SyncLayersAndCanvas(MinintDocument? doc)
|
}
|
||||||
{
|
|
||||||
Layers.Clear();
|
public void LoadContainer(MinintContainer container, string? path)
|
||||||
if (doc is not null)
|
{
|
||||||
{
|
Container = container;
|
||||||
foreach (var layer in doc.Layers)
|
FilePath = path;
|
||||||
Layers.Add(layer);
|
|
||||||
ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
|
SyncDocumentsList();
|
||||||
}
|
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
|
||||||
else
|
}
|
||||||
{
|
|
||||||
ActiveLayer = null;
|
partial void OnActiveDocumentChanged(MinintDocument? value)
|
||||||
}
|
{
|
||||||
|
if (_suppressDocumentSync) return;
|
||||||
RefreshCanvas();
|
SyncLayersAndCanvas(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshCanvas()
|
public void SelectDocument(MinintDocument? doc)
|
||||||
{
|
{
|
||||||
if (Container is null || ActiveDocument is null)
|
_suppressDocumentSync = true;
|
||||||
{
|
ActiveDocument = doc;
|
||||||
CanvasBitmap = null;
|
_suppressDocumentSync = false;
|
||||||
return;
|
SyncLayersAndCanvas(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
int w = Container.Width;
|
/// <summary>
|
||||||
int h = Container.Height;
|
/// Re-syncs the Documents observable collection after an external modification
|
||||||
uint[] argb = _compositor.Composite(ActiveDocument, w, h);
|
/// to Container.Documents (e.g. pattern generation adding a document).
|
||||||
|
/// </summary>
|
||||||
var bmp = new WriteableBitmap(
|
public void SyncAfterExternalChange() => SyncDocumentsList();
|
||||||
new PixelSize(w, h),
|
|
||||||
new Vector(96, 96),
|
private void SyncDocumentsList()
|
||||||
Avalonia.Platform.PixelFormat.Bgra8888);
|
{
|
||||||
|
Documents.Clear();
|
||||||
using (var fb = bmp.Lock())
|
if (Container is null) return;
|
||||||
{
|
foreach (var doc in Container.Documents)
|
||||||
unsafe
|
Documents.Add(doc);
|
||||||
{
|
}
|
||||||
var dst = new Span<uint>((void*)fb.Address, w * h);
|
|
||||||
for (int i = 0; i < argb.Length; i++)
|
private void SyncLayersAndCanvas(MinintDocument? doc)
|
||||||
{
|
{
|
||||||
// argb[i] is 0xAARRGGBB, need premultiplied BGRA for the bitmap
|
UnsubscribeLayerVisibility();
|
||||||
uint px = argb[i];
|
Layers.Clear();
|
||||||
byte a = (byte)(px >> 24);
|
if (doc is not null)
|
||||||
byte r = (byte)((px >> 16) & 0xFF);
|
{
|
||||||
byte g = (byte)((px >> 8) & 0xFF);
|
foreach (var layer in doc.Layers)
|
||||||
byte b = (byte)(px & 0xFF);
|
Layers.Add(layer);
|
||||||
|
ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
|
||||||
if (a == 255)
|
}
|
||||||
{
|
else
|
||||||
dst[i] = px; // ARGB layout == BGRA in LE memory, alpha=255 → no premul needed
|
{
|
||||||
}
|
ActiveLayer = null;
|
||||||
else if (a == 0)
|
}
|
||||||
{
|
SubscribeLayerVisibility();
|
||||||
dst[i] = 0;
|
RefreshCanvas();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
#endregion
|
||||||
r = (byte)(r * a / 255);
|
|
||||||
g = (byte)(g * a / 255);
|
#region Layer visibility change tracking
|
||||||
b = (byte)(b * a / 255);
|
|
||||||
dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
|
private void SubscribeLayerVisibility()
|
||||||
}
|
{
|
||||||
}
|
foreach (var layer in Layers)
|
||||||
}
|
{
|
||||||
}
|
if (layer is INotifyPropertyChanged npc)
|
||||||
|
npc.PropertyChanged += OnLayerPropertyChanged;
|
||||||
CanvasBitmap = bmp;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ICompositor Compositor => _compositor;
|
private void UnsubscribeLayerVisibility()
|
||||||
public IPaletteService PaletteService => _paletteService;
|
{
|
||||||
}
|
foreach (var layer in Layers)
|
||||||
|
{
|
||||||
|
if (layer is INotifyPropertyChanged npc)
|
||||||
|
npc.PropertyChanged -= OnLayerPropertyChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLayerPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(MinintLayer.IsVisible) or nameof(MinintLayer.Opacity))
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Document commands
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddDocument()
|
||||||
|
{
|
||||||
|
if (Container is null) return;
|
||||||
|
int num = Container.Documents.Count + 1;
|
||||||
|
var doc = Container.AddNewDocument($"Document {num}");
|
||||||
|
Documents.Add(doc);
|
||||||
|
SelectDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveDocument()
|
||||||
|
{
|
||||||
|
if (Container is null || ActiveDocument is null) return;
|
||||||
|
if (Container.Documents.Count <= 1) return; // keep at least one
|
||||||
|
|
||||||
|
var doc = ActiveDocument;
|
||||||
|
int idx = Container.Documents.IndexOf(doc);
|
||||||
|
Container.Documents.Remove(doc);
|
||||||
|
Documents.Remove(doc);
|
||||||
|
|
||||||
|
int newIdx = Math.Min(idx, Container.Documents.Count - 1);
|
||||||
|
SelectDocument(newIdx >= 0 ? Container.Documents[newIdx] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RenameDocument()
|
||||||
|
{
|
||||||
|
// Triggered via UI text edit — the Name property is directly editable via TextBox
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void MoveDocumentUp()
|
||||||
|
{
|
||||||
|
if (Container is null || ActiveDocument is null) return;
|
||||||
|
int idx = Container.Documents.IndexOf(ActiveDocument);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
(Container.Documents[idx], Container.Documents[idx - 1]) = (Container.Documents[idx - 1], Container.Documents[idx]);
|
||||||
|
SyncDocumentsList();
|
||||||
|
_suppressDocumentSync = true;
|
||||||
|
ActiveDocument = Container.Documents[idx - 1];
|
||||||
|
_suppressDocumentSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void MoveDocumentDown()
|
||||||
|
{
|
||||||
|
if (Container is null || ActiveDocument is null) return;
|
||||||
|
int idx = Container.Documents.IndexOf(ActiveDocument);
|
||||||
|
if (idx < 0 || idx >= Container.Documents.Count - 1) return;
|
||||||
|
(Container.Documents[idx], Container.Documents[idx + 1]) = (Container.Documents[idx + 1], Container.Documents[idx]);
|
||||||
|
SyncDocumentsList();
|
||||||
|
_suppressDocumentSync = true;
|
||||||
|
ActiveDocument = Container.Documents[idx + 1];
|
||||||
|
_suppressDocumentSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Layer commands
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddLayer()
|
||||||
|
{
|
||||||
|
if (Container is null || ActiveDocument is null) return;
|
||||||
|
int num = ActiveDocument.Layers.Count + 1;
|
||||||
|
var layer = new MinintLayer($"Layer {num}", Container.PixelCount);
|
||||||
|
ActiveDocument.Layers.Add(layer);
|
||||||
|
Layers.Add(layer);
|
||||||
|
ActiveLayer = layer;
|
||||||
|
SubscribeLayerVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveLayer()
|
||||||
|
{
|
||||||
|
if (ActiveDocument is null || ActiveLayer is null) return;
|
||||||
|
if (ActiveDocument.Layers.Count <= 1) return; // keep at least one
|
||||||
|
|
||||||
|
UnsubscribeLayerVisibility();
|
||||||
|
var layer = ActiveLayer;
|
||||||
|
int idx = ActiveDocument.Layers.IndexOf(layer);
|
||||||
|
ActiveDocument.Layers.Remove(layer);
|
||||||
|
Layers.Remove(layer);
|
||||||
|
|
||||||
|
int newIdx = Math.Min(idx, ActiveDocument.Layers.Count - 1);
|
||||||
|
ActiveLayer = newIdx >= 0 ? ActiveDocument.Layers[newIdx] : null;
|
||||||
|
SubscribeLayerVisibility();
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void MoveLayerUp()
|
||||||
|
{
|
||||||
|
if (ActiveDocument is null || ActiveLayer is null) return;
|
||||||
|
int idx = ActiveDocument.Layers.IndexOf(ActiveLayer);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
|
||||||
|
UnsubscribeLayerVisibility();
|
||||||
|
var layer = ActiveLayer;
|
||||||
|
(ActiveDocument.Layers[idx], ActiveDocument.Layers[idx - 1]) = (ActiveDocument.Layers[idx - 1], ActiveDocument.Layers[idx]);
|
||||||
|
Layers.Move(idx, idx - 1);
|
||||||
|
ActiveLayer = layer;
|
||||||
|
SubscribeLayerVisibility();
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void MoveLayerDown()
|
||||||
|
{
|
||||||
|
if (ActiveDocument is null || ActiveLayer is null) return;
|
||||||
|
int idx = ActiveDocument.Layers.IndexOf(ActiveLayer);
|
||||||
|
if (idx < 0 || idx >= ActiveDocument.Layers.Count - 1) return;
|
||||||
|
|
||||||
|
UnsubscribeLayerVisibility();
|
||||||
|
var layer = ActiveLayer;
|
||||||
|
(ActiveDocument.Layers[idx], ActiveDocument.Layers[idx + 1]) = (ActiveDocument.Layers[idx + 1], ActiveDocument.Layers[idx]);
|
||||||
|
Layers.Move(idx, idx + 1);
|
||||||
|
ActiveLayer = layer;
|
||||||
|
SubscribeLayerVisibility();
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void DuplicateLayer()
|
||||||
|
{
|
||||||
|
if (Container is null || ActiveDocument is null || ActiveLayer is null) return;
|
||||||
|
var src = ActiveLayer;
|
||||||
|
var dup = new MinintLayer(src.Name + " copy", src.IsVisible, src.Opacity, (int[])src.Pixels.Clone());
|
||||||
|
int idx = ActiveDocument.Layers.IndexOf(src) + 1;
|
||||||
|
ActiveDocument.Layers.Insert(idx, dup);
|
||||||
|
|
||||||
|
UnsubscribeLayerVisibility();
|
||||||
|
Layers.Insert(idx, dup);
|
||||||
|
ActiveLayer = dup;
|
||||||
|
SubscribeLayerVisibility();
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Drawing
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnToolDrag(int px, int py)
|
||||||
|
{
|
||||||
|
if (ActiveTool == ToolType.Fill) return;
|
||||||
|
OnToolDown(px, py);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Fragment copy (A4)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies a rectangular fragment from <paramref name="srcDoc"/> active layer
|
||||||
|
/// to <paramref name="dstDoc"/> active layer.
|
||||||
|
/// </summary>
|
||||||
|
public void CopyFragment(
|
||||||
|
MinintDocument srcDoc, int srcLayerIndex,
|
||||||
|
int srcX, int srcY, int regionW, int regionH,
|
||||||
|
MinintDocument dstDoc, int dstLayerIndex,
|
||||||
|
int dstX, int dstY)
|
||||||
|
{
|
||||||
|
if (Container is null) return;
|
||||||
|
_fragmentService.CopyFragment(
|
||||||
|
srcDoc, srcLayerIndex, srcX, srcY, regionW, regionH,
|
||||||
|
dstDoc, dstLayerIndex, dstX, dstY,
|
||||||
|
Container.Width, Container.Height);
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
public IFragmentService FragmentService => _fragmentService;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
using Minint.Infrastructure.Serialization;
|
using Minint.Infrastructure.Serialization;
|
||||||
|
using Minint.Views;
|
||||||
|
|
||||||
namespace Minint.ViewModels;
|
namespace Minint.ViewModels;
|
||||||
|
|
||||||
public partial class MainWindowViewModel : ViewModelBase
|
public partial class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly MinintSerializer _serializer = new();
|
private readonly MinintSerializer _serializer = new();
|
||||||
|
private readonly IImageEffectsService _effects = new ImageEffectsService();
|
||||||
|
private readonly IPatternGenerator _patternGen = new PatternGenerator();
|
||||||
|
|
||||||
private static readonly FilePickerFileType MinintFileType = new("Minint Files")
|
private static readonly FilePickerFileType MinintFileType = new("Minint Files")
|
||||||
{
|
{
|
||||||
@@ -25,11 +31,10 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _statusText = "Ready";
|
private string _statusText = "Ready";
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set by the view so that file dialogs can use the correct parent window.
|
|
||||||
/// </summary>
|
|
||||||
public TopLevel? Owner { get; set; }
|
public TopLevel? Owner { get; set; }
|
||||||
|
|
||||||
|
#region File commands
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void NewFile()
|
private void NewFile()
|
||||||
{
|
{
|
||||||
@@ -72,13 +77,9 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
if (Editor.Container is null) return;
|
if (Editor.Container is null) return;
|
||||||
|
|
||||||
if (Editor.FilePath is not null)
|
if (Editor.FilePath is not null)
|
||||||
{
|
|
||||||
await SaveToPathAsync(Editor.FilePath);
|
await SaveToPathAsync(Editor.FilePath);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
await SaveFileAsAsync();
|
await SaveFileAsAsync();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -121,4 +122,111 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
StatusText = $"Error saving file: {ex.Message}";
|
StatusText = $"Error saving file: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Effects (A1, A2)
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ApplyContrastAsync()
|
||||||
|
{
|
||||||
|
if (Editor.ActiveDocument is null || Owner is not Window window) return;
|
||||||
|
|
||||||
|
var dialog = new ContrastDialog();
|
||||||
|
var result = await dialog.ShowDialog<bool?>(window);
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
_effects.ApplyContrast(Editor.ActiveDocument, dialog.Factor);
|
||||||
|
Editor.RefreshCanvas();
|
||||||
|
StatusText = $"Contrast ×{dialog.Factor:F1} applied.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ApplyGrayscale()
|
||||||
|
{
|
||||||
|
if (Editor.ActiveDocument is null) return;
|
||||||
|
|
||||||
|
_effects.ApplyGrayscale(Editor.ActiveDocument);
|
||||||
|
Editor.RefreshCanvas();
|
||||||
|
StatusText = "Grayscale applied.";
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Copy fragment (A4)
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CopyFragmentAsync()
|
||||||
|
{
|
||||||
|
if (Editor.Container is null || Owner is not Window window) return;
|
||||||
|
|
||||||
|
var docs = Editor.Container.Documents;
|
||||||
|
if (docs.Count == 0) return;
|
||||||
|
|
||||||
|
var dialog = new CopyFragmentDialog(docs, Editor.Container.Width, Editor.Container.Height);
|
||||||
|
var result = await dialog.ShowDialog<bool?>(window);
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
if (dialog.SourceDocument is null || dialog.DestDocument is null)
|
||||||
|
{
|
||||||
|
StatusText = "Copy failed: no document selected.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dialog.SourceLayerIndex < 0 || dialog.DestLayerIndex < 0)
|
||||||
|
{
|
||||||
|
StatusText = "Copy failed: no layer selected.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Editor.CopyFragment(
|
||||||
|
dialog.SourceDocument, dialog.SourceLayerIndex,
|
||||||
|
dialog.SourceX, dialog.SourceY,
|
||||||
|
dialog.FragmentWidth, dialog.FragmentHeight,
|
||||||
|
dialog.DestDocument, dialog.DestLayerIndex,
|
||||||
|
dialog.DestX, dialog.DestY);
|
||||||
|
StatusText = $"Copied {dialog.FragmentWidth}×{dialog.FragmentHeight} fragment.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText = $"Copy failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Pattern generation (Б4)
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task GeneratePatternAsync()
|
||||||
|
{
|
||||||
|
if (Editor.Container is null || Owner is not Window window) return;
|
||||||
|
|
||||||
|
var dialog = new PatternDialog();
|
||||||
|
var result = await dialog.ShowDialog<bool?>(window);
|
||||||
|
if (result != true) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = _patternGen.Generate(
|
||||||
|
dialog.SelectedPattern,
|
||||||
|
Editor.Container.Width,
|
||||||
|
Editor.Container.Height,
|
||||||
|
[dialog.PatternColor1, dialog.PatternColor2],
|
||||||
|
dialog.PatternParam1,
|
||||||
|
dialog.PatternParam2);
|
||||||
|
|
||||||
|
Editor.Container.Documents.Add(doc);
|
||||||
|
Editor.SyncAfterExternalChange();
|
||||||
|
Editor.SelectDocument(doc);
|
||||||
|
StatusText = $"Pattern '{dialog.SelectedPattern}' generated.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText = $"Pattern generation failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Minint/Views/ContrastDialog.axaml
Normal file
20
Minint/Views/ContrastDialog.axaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Minint.Views.ContrastDialog"
|
||||||
|
Title="Adjust Contrast"
|
||||||
|
Width="320"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
SizeToContent="Height">
|
||||||
|
<StackPanel Margin="16" Spacing="10">
|
||||||
|
<TextBlock Text="Contrast factor (0 = gray, 1 = no change, >1 = more contrast):"/>
|
||||||
|
<Slider x:Name="FactorSlider" Minimum="0" Maximum="3" Value="1"
|
||||||
|
TickFrequency="0.1" IsSnapToTickEnabled="True"/>
|
||||||
|
<TextBlock x:Name="FactorLabel" Text="1.0" HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,8,0,0">
|
||||||
|
<Button Content="Apply" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
|
||||||
|
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
24
Minint/Views/ContrastDialog.axaml.cs
Normal file
24
Minint/Views/ContrastDialog.axaml.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace Minint.Views;
|
||||||
|
|
||||||
|
public partial class ContrastDialog : Window
|
||||||
|
{
|
||||||
|
public double Factor => FactorSlider.Value;
|
||||||
|
|
||||||
|
public ContrastDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
FactorSlider.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Property == Slider.ValueProperty)
|
||||||
|
FactorLabel.Text = FactorSlider.Value.ToString("F1");
|
||||||
|
};
|
||||||
|
|
||||||
|
OkButton.Click += (_, _) => Close(true);
|
||||||
|
CancelButton.Click += (_, _) => Close(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Minint/Views/CopyFragmentDialog.axaml
Normal file
51
Minint/Views/CopyFragmentDialog.axaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Minint.Views.CopyFragmentDialog"
|
||||||
|
Title="Copy Fragment"
|
||||||
|
Width="380" Height="420"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
SizeToContent="Height">
|
||||||
|
<StackPanel Margin="16" Spacing="8">
|
||||||
|
<TextBlock Text="Source" FontWeight="SemiBold"/>
|
||||||
|
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto" RowSpacing="4" ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Document:" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Row="0" Grid.Column="1" x:Name="SrcDocCombo"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Layer:" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="SrcLayerCombo"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="X:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="SrcX" Value="0" Minimum="0" FormatString="0"/>
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="Y:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="3" Grid.Column="1" x:Name="SrcY" Value="0" Minimum="0" FormatString="0"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto" RowSpacing="4" ColumnSpacing="8" Margin="0,4,0,0">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Width:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="0" Grid.Column="1" x:Name="RegionW" Value="16" Minimum="1" FormatString="0"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Height:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="RegionH" Value="16" Minimum="1" FormatString="0"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Separator Margin="0,8"/>
|
||||||
|
<TextBlock Text="Destination" FontWeight="SemiBold"/>
|
||||||
|
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="4" ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Document:" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Row="0" Grid.Column="1" x:Name="DstDocCombo"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Layer:" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="DstLayerCombo"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="X:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="DstX" Value="0" Minimum="0" FormatString="0"/>
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="Y:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="3" Grid.Column="1" x:Name="DstY" Value="0" Minimum="0" FormatString="0"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,12,0,0">
|
||||||
|
<Button Content="Copy" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
|
||||||
|
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
91
Minint/Views/CopyFragmentDialog.axaml.cs
Normal file
91
Minint/Views/CopyFragmentDialog.axaml.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
|
||||||
|
namespace Minint.Views;
|
||||||
|
|
||||||
|
public partial class CopyFragmentDialog : Window
|
||||||
|
{
|
||||||
|
private readonly List<MinintDocument> _documents;
|
||||||
|
|
||||||
|
public MinintDocument? SourceDocument => SrcDocCombo.SelectedItem as MinintDocument;
|
||||||
|
public int SourceLayerIndex => SrcLayerCombo.SelectedIndex;
|
||||||
|
public int SourceX => (int)(SrcX.Value ?? 0);
|
||||||
|
public int SourceY => (int)(SrcY.Value ?? 0);
|
||||||
|
public int FragmentWidth => (int)(RegionW.Value ?? 1);
|
||||||
|
public int FragmentHeight => (int)(RegionH.Value ?? 1);
|
||||||
|
public MinintDocument? DestDocument => DstDocCombo.SelectedItem as MinintDocument;
|
||||||
|
public int DestLayerIndex => DstLayerCombo.SelectedIndex;
|
||||||
|
public int DestX => (int)(DstX.Value ?? 0);
|
||||||
|
public int DestY => (int)(DstY.Value ?? 0);
|
||||||
|
|
||||||
|
public CopyFragmentDialog()
|
||||||
|
{
|
||||||
|
_documents = [];
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CopyFragmentDialog(List<MinintDocument> documents, int maxW, int maxH) : this()
|
||||||
|
{
|
||||||
|
_documents = documents;
|
||||||
|
|
||||||
|
SrcDocCombo.ItemsSource = documents;
|
||||||
|
SrcDocCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
||||||
|
DstDocCombo.ItemsSource = documents;
|
||||||
|
DstDocCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
||||||
|
|
||||||
|
SrcX.Maximum = maxW - 1;
|
||||||
|
SrcY.Maximum = maxH - 1;
|
||||||
|
DstX.Maximum = maxW - 1;
|
||||||
|
DstY.Maximum = maxH - 1;
|
||||||
|
RegionW.Maximum = maxW;
|
||||||
|
RegionH.Maximum = maxH;
|
||||||
|
|
||||||
|
if (documents.Count > 0)
|
||||||
|
{
|
||||||
|
SrcDocCombo.SelectedIndex = 0;
|
||||||
|
DstDocCombo.SelectedIndex = documents.Count > 1 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
SrcDocCombo.SelectionChanged += (_, _) => UpdateSrcLayers();
|
||||||
|
DstDocCombo.SelectionChanged += (_, _) => UpdateDstLayers();
|
||||||
|
|
||||||
|
UpdateSrcLayers();
|
||||||
|
UpdateDstLayers();
|
||||||
|
|
||||||
|
OkButton.Click += OnOkClick;
|
||||||
|
CancelButton.Click += OnCancelClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSrcLayers()
|
||||||
|
{
|
||||||
|
if (SrcDocCombo.SelectedItem is MinintDocument doc)
|
||||||
|
{
|
||||||
|
SrcLayerCombo.ItemsSource = doc.Layers;
|
||||||
|
SrcLayerCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
||||||
|
if (doc.Layers.Count > 0) SrcLayerCombo.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDstLayers()
|
||||||
|
{
|
||||||
|
if (DstDocCombo.SelectedItem is MinintDocument doc)
|
||||||
|
{
|
||||||
|
DstLayerCombo.ItemsSource = doc.Layers;
|
||||||
|
DstLayerCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
||||||
|
if (doc.Layers.Count > 0) DstLayerCombo.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOkClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancelClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Close(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +1,171 @@
|
|||||||
<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"
|
||||||
|
ToolTip.ShowDelay="400">
|
||||||
<Design.DataContext>
|
|
||||||
<vm:MainWindowViewModel/>
|
<Design.DataContext>
|
||||||
</Design.DataContext>
|
<vm:MainWindowViewModel/>
|
||||||
|
</Design.DataContext>
|
||||||
<DockPanel>
|
|
||||||
<!-- Menu bar -->
|
<DockPanel>
|
||||||
<Menu DockPanel.Dock="Top">
|
<!-- Menu bar -->
|
||||||
<MenuItem Header="_File">
|
<Menu DockPanel.Dock="Top">
|
||||||
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
|
<MenuItem Header="_File">
|
||||||
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
|
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
|
||||||
<Separator/>
|
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
|
||||||
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
<Separator/>
|
||||||
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
||||||
</MenuItem>
|
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
||||||
<MenuItem Header="_View">
|
</MenuItem>
|
||||||
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
<MenuItem Header="_Edit">
|
||||||
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
|
<MenuItem Header="Copy _Fragment…" Command="{Binding CopyFragmentCommand}"
|
||||||
</MenuItem>
|
ToolTip.Tip="Copy a rectangular region between documents"/>
|
||||||
</Menu>
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Image">
|
||||||
<!-- Status bar -->
|
<MenuItem Header="Adjust _Contrast…" Command="{Binding ApplyContrastCommand}"
|
||||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
ToolTip.Tip="Adjust contrast of the active document's palette"/>
|
||||||
Padding="8,2">
|
<MenuItem Header="Convert to _Grayscale" Command="{Binding ApplyGrayscaleCommand}"
|
||||||
<TextBlock Text="{Binding StatusText}" FontSize="12"/>
|
ToolTip.Tip="Convert active document to grayscale"/>
|
||||||
</Border>
|
<Separator/>
|
||||||
|
<MenuItem Header="Generate _Pattern…" Command="{Binding GeneratePatternCommand}"
|
||||||
<!-- Main content: left panel, canvas, right panel -->
|
ToolTip.Tip="Generate a new document with a parametric pattern"/>
|
||||||
<Grid ColumnDefinitions="180,*,180">
|
</MenuItem>
|
||||||
|
<MenuItem Header="_View">
|
||||||
<!-- Left panel: documents -->
|
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
||||||
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
|
||||||
BorderThickness="0,0,1,0" Padding="4">
|
</MenuItem>
|
||||||
<DockPanel>
|
</Menu>
|
||||||
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
|
||||||
<ListBox ItemsSource="{Binding Editor.Documents}"
|
<!-- Toolbar -->
|
||||||
SelectedItem="{Binding Editor.ActiveDocument}"
|
<Border DockPanel.Dock="Top"
|
||||||
SelectionMode="Single">
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
<ListBox.ItemTemplate>
|
BorderThickness="0,0,0,1" Padding="6,4">
|
||||||
<DataTemplate>
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Text="{Binding Name}"/>
|
<RadioButton GroupName="Tool" Content="Brush"
|
||||||
</DataTemplate>
|
ToolTip.Tip="Brush tool — draw with selected color"
|
||||||
</ListBox.ItemTemplate>
|
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsBrush}}"
|
||||||
</ListBox>
|
Command="{Binding Editor.SelectBrushCommand}"/>
|
||||||
</DockPanel>
|
<RadioButton GroupName="Tool" Content="Eraser"
|
||||||
</Border>
|
ToolTip.Tip="Eraser tool — erase to transparent"
|
||||||
|
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsEraser}}"
|
||||||
<!-- Center: canvas with scrollbars -->
|
Command="{Binding Editor.SelectEraserCommand}"/>
|
||||||
<Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto">
|
<RadioButton GroupName="Tool" Content="Fill"
|
||||||
<Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True">
|
ToolTip.Tip="Fill tool — flood fill with selected color"
|
||||||
<controls:PixelCanvas x:Name="Canvas"
|
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
|
||||||
SourceBitmap="{Binding Editor.CanvasBitmap}"
|
Command="{Binding Editor.SelectFillCommand}"/>
|
||||||
ShowGrid="{Binding Editor.ShowGrid}"/>
|
|
||||||
</Border>
|
<Separator/>
|
||||||
<ScrollBar x:Name="HScroll" Grid.Row="1" Grid.Column="0"
|
|
||||||
Orientation="Horizontal"/>
|
<TextBlock Text="Size:" VerticalAlignment="Center"/>
|
||||||
<ScrollBar x:Name="VScroll" Grid.Row="0" Grid.Column="1"
|
<Slider Value="{Binding Editor.BrushRadius}" Minimum="0" Maximum="64"
|
||||||
Orientation="Vertical"/>
|
TickFrequency="1" IsSnapToTickEnabled="True" Width="120"
|
||||||
</Grid>
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Editor.BrushRadius}" VerticalAlignment="Center"
|
||||||
<!-- Right panel: layers -->
|
Width="20" TextAlignment="Center"/>
|
||||||
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
|
||||||
BorderThickness="1,0,0,0" Padding="4">
|
<Separator/>
|
||||||
<DockPanel>
|
|
||||||
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
<TextBlock Text="Color:" VerticalAlignment="Center"/>
|
||||||
<ListBox ItemsSource="{Binding Editor.Layers}"
|
<ColorPicker x:Name="ToolColorPicker"
|
||||||
SelectedItem="{Binding Editor.ActiveLayer}"
|
Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
|
||||||
SelectionMode="Single">
|
IsAlphaVisible="False"
|
||||||
<ListBox.ItemTemplate>
|
VerticalAlignment="Center"/>
|
||||||
<DataTemplate>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
</Border>
|
||||||
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"/>
|
|
||||||
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
|
<!-- Status bar -->
|
||||||
</StackPanel>
|
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||||
</DataTemplate>
|
Padding="8,2">
|
||||||
</ListBox.ItemTemplate>
|
<TextBlock Text="{Binding StatusText}" FontSize="12"/>
|
||||||
</ListBox>
|
</Border>
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
<!-- Main content: left panel, canvas, right panel -->
|
||||||
|
<Grid ColumnDefinitions="200,*,200">
|
||||||
</Grid>
|
|
||||||
</DockPanel>
|
<!-- Left panel: documents -->
|
||||||
</Window>
|
<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"/>
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="2" Margin="0,4,0,0">
|
||||||
|
<Button Content="+" ToolTip.Tip="Add a new document (frame)"
|
||||||
|
Command="{Binding Editor.AddDocumentCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="−" ToolTip.Tip="Remove selected document"
|
||||||
|
Command="{Binding Editor.RemoveDocumentCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="▲" ToolTip.Tip="Move document up in the list"
|
||||||
|
Command="{Binding Editor.MoveDocumentUpCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="▼" ToolTip.Tip="Move document down in the list"
|
||||||
|
Command="{Binding Editor.MoveDocumentDownCommand}" Padding="6,2"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ListBox ItemsSource="{Binding Editor.Documents}"
|
||||||
|
SelectedItem="{Binding Editor.ActiveDocument}"
|
||||||
|
SelectionMode="Single">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<controls:EditableTextBlock 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"/>
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="2" Margin="0,4,0,0">
|
||||||
|
<Button Content="+" ToolTip.Tip="Add a new empty layer"
|
||||||
|
Command="{Binding Editor.AddLayerCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="−" ToolTip.Tip="Remove selected layer"
|
||||||
|
Command="{Binding Editor.RemoveLayerCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="▲" ToolTip.Tip="Move layer up (draw later, appears on top)"
|
||||||
|
Command="{Binding Editor.MoveLayerUpCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="▼" ToolTip.Tip="Move layer down (draw earlier, appears below)"
|
||||||
|
Command="{Binding Editor.MoveLayerDownCommand}" Padding="6,2"/>
|
||||||
|
<Button Content="⧉" ToolTip.Tip="Duplicate selected layer with all pixels"
|
||||||
|
Command="{Binding Editor.DuplicateLayerCommand}" Padding="6,2"/>
|
||||||
|
</StackPanel>
|
||||||
|
<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"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
ToolTip.Tip="Toggle layer visibility"/>
|
||||||
|
<controls:EditableTextBlock Text="{Binding Name}"
|
||||||
|
VerticalAlignment="Center" MinWidth="60"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
36
Minint/Views/PatternDialog.axaml
Normal file
36
Minint/Views/PatternDialog.axaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="Minint.Views.PatternDialog"
|
||||||
|
Title="Generate Pattern"
|
||||||
|
Width="360"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
CanResize="False"
|
||||||
|
SizeToContent="Height">
|
||||||
|
<StackPanel Margin="16" Spacing="8">
|
||||||
|
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="6" ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Pattern:" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Row="0" Grid.Column="1" x:Name="PatternCombo" HorizontalAlignment="Stretch"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Color 1:" VerticalAlignment="Center"/>
|
||||||
|
<ColorPicker Grid.Row="1" Grid.Column="1" x:Name="Color1Picker" IsAlphaVisible="False"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="Color 2:" VerticalAlignment="Center"/>
|
||||||
|
<ColorPicker Grid.Row="2" Grid.Column="1" x:Name="Color2Picker" IsAlphaVisible="False"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="Param 1:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="3" Grid.Column="1" x:Name="Param1"
|
||||||
|
Value="8" Minimum="1" Maximum="256" FormatString="0"
|
||||||
|
ToolTip.Tip="Cell/stripe/ring size"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="0" Text="Param 2:" VerticalAlignment="Center"/>
|
||||||
|
<NumericUpDown Grid.Row="4" Grid.Column="1" x:Name="Param2"
|
||||||
|
Value="8" Minimum="1" Maximum="256" FormatString="0"
|
||||||
|
ToolTip.Tip="Tile height (for Tile pattern)"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,12,0,0">
|
||||||
|
<Button Content="Generate" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
|
||||||
|
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
50
Minint/Views/PatternDialog.axaml.cs
Normal file
50
Minint/Views/PatternDialog.axaml.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services;
|
||||||
|
|
||||||
|
namespace Minint.Views;
|
||||||
|
|
||||||
|
public partial class PatternDialog : Window
|
||||||
|
{
|
||||||
|
public PatternType SelectedPattern =>
|
||||||
|
PatternCombo.SelectedItem is PatternType pt ? pt : PatternType.Checkerboard;
|
||||||
|
|
||||||
|
public RgbaColor PatternColor1
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var c = Color1Picker.Color;
|
||||||
|
return new RgbaColor(c.R, c.G, c.B, c.A);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RgbaColor PatternColor2
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var c = Color2Picker.Color;
|
||||||
|
return new RgbaColor(c.R, c.G, c.B, c.A);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int PatternParam1 => (int)(Param1.Value ?? 8);
|
||||||
|
public int PatternParam2 => (int)(Param2.Value ?? 8);
|
||||||
|
|
||||||
|
public PatternDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
PatternCombo.ItemsSource = Enum.GetValues<PatternType>().ToList();
|
||||||
|
PatternCombo.SelectedIndex = 0;
|
||||||
|
|
||||||
|
Color1Picker.Color = Color.FromRgb(0, 0, 0);
|
||||||
|
Color2Picker.Color = Color.FromRgb(255, 255, 255);
|
||||||
|
|
||||||
|
OkButton.Click += (_, _) => Close(true);
|
||||||
|
CancelButton.Click += (_, _) => Close(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user