Compare commits

...

3 Commits

Author SHA1 Message Date
3a61e0a07d Этап 9 2026-03-29 17:25:33 +03:00
c3961fcba7 Этап 8 2026-03-29 16:51:43 +03:00
25e30416a3 Этап 7 2026-03-29 16:42:40 +03:00
24 changed files with 2044 additions and 667 deletions

View File

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

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

View File

@@ -0,0 +1,43 @@
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class DrawingService : IDrawingService
{
public void ApplyBrush(MinintLayer layer, int cx, int cy, int radius, int colorIndex, int width, int height)
{
foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
layer.Pixels[y * width + x] = colorIndex;
}
public void ApplyEraser(MinintLayer layer, int cx, int cy, int radius, int width, int height)
{
foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
layer.Pixels[y * width + x] = 0;
}
public List<(int X, int Y)> GetBrushMask(int cx, int cy, int radius, int width, int height)
{
var mask = new List<(int, int)>();
int r = Math.Max(radius, 0);
int r2 = r * r;
int xMin = Math.Max(0, cx - r);
int xMax = Math.Min(width - 1, cx + r);
int yMin = Math.Max(0, cy - r);
int yMax = Math.Min(height - 1, cy + r);
for (int py = yMin; py <= yMax; py++)
{
int dy = py - cy;
for (int px = xMin; px <= xMax; px++)
{
int dx = px - cx;
if (dx * dx + dy * dy <= r2)
mask.Add((px, py));
}
}
return mask;
}
}

View File

@@ -0,0 +1,47 @@
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class FloodFillService : IFloodFillService
{
public void Fill(MinintLayer layer, int x, int y, int newColorIndex, int width, int height)
{
if (x < 0 || x >= width || y < 0 || y >= height)
return;
var pixels = layer.Pixels;
int targetIndex = pixels[y * width + x];
if (targetIndex == newColorIndex)
return;
var queue = new Queue<(int X, int Y)>();
var visited = new bool[width * height];
queue.Enqueue((x, y));
visited[y * width + x] = true;
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
pixels[cy * width + cx] = newColorIndex;
Span<(int, int)> neighbors =
[
(cx - 1, cy), (cx + 1, cy),
(cx, cy - 1), (cx, cy + 1)
];
foreach (var (nx, ny) in neighbors)
{
if (nx < 0 || nx >= width || ny < 0 || ny >= height)
continue;
int ni = ny * width + nx;
if (visited[ni] || pixels[ni] != targetIndex)
continue;
visited[ni] = true;
queue.Enqueue((nx, ny));
}
}
}
}

View File

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

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

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
@@ -9,18 +10,6 @@ using Avalonia.Threading;
namespace Minint.Controls; namespace Minint.Controls;
/// <summary>
/// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom).
/// Supports nearest-neighbor scaling, pixel grid overlay, scrollbars, and mouse interaction.
/// <para>Input model:</para>
/// <list type="bullet">
/// <item>Ctrl+Wheel: zoom at cursor</item>
/// <item>Wheel: scroll vertically</item>
/// <item>Shift+Wheel: scroll horizontally</item>
/// <item>Touchpad two-finger scroll: free pan (Delta.X + Delta.Y)</item>
/// <item>Middle mouse drag: pan</item>
/// </list>
/// </summary>
public class PixelCanvas : Control public class PixelCanvas : Control
{ {
#region Styled Properties #region Styled Properties
@@ -45,11 +34,32 @@ public class PixelCanvas : Control
#endregion #endregion
#region Events for tool interaction
/// <summary>Fires when the user presses the left mouse button at an image pixel.</summary>
public event Action<int, int>? ToolDown;
/// <summary>Fires when the user drags with left button held at an image pixel.</summary>
public event Action<int, int>? ToolDrag;
/// <summary>Fires when the cursor moves over the image (pixel coords, or null if outside).</summary>
public event Action<(int X, int Y)?>? CursorPixelChanged;
/// <summary>Set by the host to provide preview mask pixels for overlay.</summary>
public Func<List<(int X, int Y)>?>? GetPreviewMask { get; set; }
#endregion
private readonly Viewport _viewport = new(); private readonly Viewport _viewport = new();
private bool _isPanning; private bool _isPanning;
private bool _isDrawing;
private Point _panStart; private Point _panStart;
private double _panStartOffsetX, _panStartOffsetY; private double _panStartOffsetX, _panStartOffsetY;
private bool _viewportInitialized; private bool _viewportInitialized;
private int _lastBitmapWidth;
private int _lastBitmapHeight;
private (int X, int Y)? _lastCursorPixel;
private Point? _lastScreenPos;
private ScrollBar? _hScrollBar; private ScrollBar? _hScrollBar;
private ScrollBar? _vScrollBar; private ScrollBar? _vScrollBar;
@@ -70,19 +80,12 @@ public class PixelCanvas : Control
ClipToBounds = true; ClipToBounds = true;
} }
/// <summary>
/// Connects external ScrollBar controls. Call once after the UI is built.
/// </summary>
public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical) public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
{ {
if (_hScrollBar is not null) if (_hScrollBar is not null) _hScrollBar.ValueChanged -= OnHScrollChanged;
_hScrollBar.ValueChanged -= OnHScrollChanged; if (_vScrollBar is not null) _vScrollBar.ValueChanged -= OnVScrollChanged;
if (_vScrollBar is not null)
_vScrollBar.ValueChanged -= OnVScrollChanged;
_hScrollBar = horizontal; _hScrollBar = horizontal;
_vScrollBar = vertical; _vScrollBar = vertical;
_hScrollBar.ValueChanged += OnHScrollChanged; _hScrollBar.ValueChanged += OnHScrollChanged;
_vScrollBar.ValueChanged += OnVScrollChanged; _vScrollBar.ValueChanged += OnVScrollChanged;
} }
@@ -95,8 +98,7 @@ public class PixelCanvas : Control
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
var bmp = SourceBitmap; var bmp = SourceBitmap;
if (bmp is null) if (bmp is null) return;
return;
int imgW = bmp.PixelSize.Width; int imgW = bmp.PixelSize.Width;
int imgH = bmp.PixelSize.Height; int imgH = bmp.PixelSize.Height;
@@ -117,7 +119,8 @@ public class PixelCanvas : Control
if (ShowGrid && _viewport.Zoom >= 4) if (ShowGrid && _viewport.Zoom >= 4)
DrawPixelGrid(context, imgW, imgH); DrawPixelGrid(context, imgW, imgH);
// Defer scrollbar sync — updating layout properties during Render is forbidden DrawToolPreview(context, imgW, imgH);
int w = imgW, h = imgH; int w = imgW, h = imgH;
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render); Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
} }
@@ -136,10 +139,8 @@ public class PixelCanvas : Control
using (context.PushClip(visible)) using (context.PushClip(visible))
{ {
context.FillRectangle(light, visible); context.FillRectangle(light, visible);
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2)); double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2)); double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
for (double y = startY; y < visible.Bottom; y += checkerSize) for (double y = startY; y < visible.Bottom; y += checkerSize)
{ {
for (double x = startX; x < visible.Right; x += checkerSize) for (double x = startX; x < visible.Right; x += checkerSize)
@@ -175,7 +176,6 @@ public class PixelCanvas : Control
var (sx, _) = _viewport.PixelToScreen(px, 0); var (sx, _) = _viewport.PixelToScreen(px, 0);
context.DrawLine(pen, new Point(sx, visible.Top), new Point(sx, visible.Bottom)); 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);
@@ -184,6 +184,44 @@ public class PixelCanvas : Control
} }
} }
private void DrawToolPreview(DrawingContext context, int imgW, int imgH)
{
var mask = GetPreviewMask?.Invoke();
if (mask is null || mask.Count == 0) return;
double zoom = _viewport.Zoom;
var previewBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255));
var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), 1);
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
using (context.PushClip(clip))
{
foreach (var (px, py) in mask)
{
var (sx, sy) = _viewport.PixelToScreen(px, py);
var r = new Rect(sx, sy, zoom, zoom);
context.FillRectangle(previewBrush, r);
}
// Outline around the mask bounding box
if (mask.Count > 0)
{
int minX = mask[0].X, maxX = mask[0].X;
int minY = mask[0].Y, maxY = mask[0].Y;
foreach (var (px, py) in mask)
{
if (px < minX) minX = px;
if (px > maxX) maxX = px;
if (py < minY) minY = py;
if (py > maxY) maxY = py;
}
var (ox, oy) = _viewport.PixelToScreen(minX, minY);
var outlineRect = new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom);
context.DrawRectangle(outlinePen, outlineRect);
}
}
}
#endregion #endregion
#region Scrollbar Sync #region Scrollbar Sync
@@ -191,10 +229,8 @@ public class PixelCanvas : Control
private void SyncScrollBars(int imgW, int imgH) private void SyncScrollBars(int imgW, int imgH)
{ {
if (_hScrollBar is null || _vScrollBar is null) return; if (_hScrollBar is null || _vScrollBar is null) return;
_suppressScrollSync = true; _suppressScrollSync = true;
// Scrollbar value is negated offset: increasing value = scroll right = offset decreases
var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX); var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
_hScrollBar.Minimum = -hMax; _hScrollBar.Minimum = -hMax;
_hScrollBar.Maximum = -hMin; _hScrollBar.Maximum = -hMin;
@@ -214,8 +250,8 @@ public class PixelCanvas : Control
{ {
if (_suppressScrollSync) return; if (_suppressScrollSync) return;
var (imgW, imgH) = GetImageSize(); var (imgW, imgH) = GetImageSize();
_viewport.SetOffset(-e.NewValue, _viewport.OffsetY, _viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height);
imgW, imgH, Bounds.Width, Bounds.Height); RecalcCursorPixel();
InvalidateVisual(); InvalidateVisual();
} }
@@ -223,8 +259,8 @@ public class PixelCanvas : Control
{ {
if (_suppressScrollSync) return; if (_suppressScrollSync) return;
var (imgW, imgH) = GetImageSize(); var (imgW, imgH) = GetImageSize();
_viewport.SetOffset(_viewport.OffsetX, -e.NewValue, _viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height);
imgW, imgH, Bounds.Width, Bounds.Height); RecalcCursorPixel();
InvalidateVisual(); InvalidateVisual();
} }
@@ -238,6 +274,30 @@ public class PixelCanvas : Control
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0); return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
} }
private (int X, int Y)? ScreenToPixelClamped(Point pos)
{
var (imgW, imgH) = GetImageSize();
if (imgW == 0) return null;
var (px, py) = _viewport.ScreenToPixel(pos.X, pos.Y);
if (px < 0 || px >= imgW || py < 0 || py >= imgH)
return null;
return (px, py);
}
/// <summary>
/// Recalculates the pixel coordinate under the cursor after a viewport change.
/// </summary>
private void RecalcCursorPixel()
{
if (_lastScreenPos is null) return;
var pixel = ScreenToPixelClamped(_lastScreenPos.Value);
if (pixel != _lastCursorPixel)
{
_lastCursorPixel = pixel;
CursorPixelChanged?.Invoke(pixel);
}
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e) protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{ {
base.OnPointerWheelChanged(e); base.OnPointerWheelChanged(e);
@@ -250,23 +310,21 @@ public class PixelCanvas : Control
if (ctrl) if (ctrl)
{ {
var pos = e.GetPosition(this); var pos = e.GetPosition(this);
_viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, _viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, imgW, imgH, Bounds.Width, Bounds.Height);
imgW, imgH, Bounds.Width, Bounds.Height);
} }
else else
{ {
double dx = e.Delta.X * ScrollPixelsPerTick; double dx = e.Delta.X * ScrollPixelsPerTick;
double dy = e.Delta.Y * ScrollPixelsPerTick; double dy = e.Delta.Y * ScrollPixelsPerTick;
if (shift && Math.Abs(e.Delta.X) < 0.001) if (shift && Math.Abs(e.Delta.X) < 0.001)
{ {
dx = dy; dx = dy;
dy = 0; dy = 0;
} }
_viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height); _viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
} }
RecalcCursorPixel();
InvalidateVisual(); InvalidateVisual();
e.Handled = true; e.Handled = true;
} }
@@ -274,8 +332,9 @@ public class PixelCanvas : Control
protected override void OnPointerPressed(PointerPressedEventArgs e) protected override void OnPointerPressed(PointerPressedEventArgs e)
{ {
base.OnPointerPressed(e); base.OnPointerPressed(e);
var props = e.GetCurrentPoint(this).Properties;
if (e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed) if (props.IsMiddleButtonPressed)
{ {
_isPanning = true; _isPanning = true;
_panStart = e.GetPosition(this); _panStart = e.GetPosition(this);
@@ -283,22 +342,49 @@ public class PixelCanvas : Control
_panStartOffsetY = _viewport.OffsetY; _panStartOffsetY = _viewport.OffsetY;
e.Handled = true; 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) protected override void OnPointerMoved(PointerEventArgs e)
{ {
base.OnPointerMoved(e); base.OnPointerMoved(e);
var pos = e.GetPosition(this);
_lastScreenPos = pos;
if (_isPanning) if (_isPanning)
{ {
var pos = e.GetPosition(this);
var (imgW, imgH) = GetImageSize(); var (imgW, imgH) = GetImageSize();
_viewport.SetOffset( _viewport.SetOffset(
_panStartOffsetX + (pos.X - _panStart.X), _panStartOffsetX + (pos.X - _panStart.X),
_panStartOffsetY + (pos.Y - _panStart.Y), _panStartOffsetY + (pos.Y - _panStart.Y),
imgW, imgH, Bounds.Width, Bounds.Height); imgW, imgH, Bounds.Width, Bounds.Height);
RecalcCursorPixel();
InvalidateVisual(); InvalidateVisual();
e.Handled = true; 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;
} }
} }
@@ -311,6 +397,23 @@ public class PixelCanvas : Control
_isPanning = false; _isPanning = false;
e.Handled = true; 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 #endregion
@@ -318,8 +421,17 @@ public class PixelCanvas : Control
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
base.OnPropertyChanged(change); base.OnPropertyChanged(change);
if (change.Property == SourceBitmapProperty) 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; _viewportInitialized = false;
} }
} }
}
}

View File

@@ -27,6 +27,7 @@
<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="Avalonia.Controls.ColorPicker" Version="11.3.8" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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.

View File

@@ -1,8 +1,12 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Minint.Core.Models; using Minint.Core.Models;
using Minint.Core.Services; using Minint.Core.Services;
using Minint.Core.Services.Impl; using Minint.Core.Services.Impl;
@@ -13,6 +17,9 @@ public partial class EditorViewModel : ViewModelBase
{ {
private readonly ICompositor _compositor = new Compositor(); private readonly ICompositor _compositor = new Compositor();
private readonly IPaletteService _paletteService = new PaletteService(); private readonly IPaletteService _paletteService = new PaletteService();
private readonly IDrawingService _drawingService = new DrawingService();
private readonly IFloodFillService _floodFillService = new FloodFillService();
private readonly IFragmentService _fragmentService = new FragmentService();
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasContainer))] [NotifyPropertyChangedFor(nameof(HasContainer))]
@@ -33,9 +40,32 @@ public partial class EditorViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _showGrid; private bool _showGrid;
/// <summary> // Tool state
/// Path of the currently open file, or null for unsaved new containers. [ObservableProperty]
/// </summary> private ToolType _activeTool = ToolType.Brush;
[ObservableProperty]
private int _brushRadius = 1;
[ObservableProperty]
private (int X, int Y)? _previewCenter;
private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
public Avalonia.Media.Color PreviewColor
{
get => _previewColor;
set
{
if (_previewColor == value) return;
_previewColor = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedColor));
}
}
public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(Title))] [NotifyPropertyChangedFor(nameof(Title))]
private string? _filePath; private string? _filePath;
@@ -51,6 +81,8 @@ public partial class EditorViewModel : ViewModelBase
public ObservableCollection<MinintDocument> Documents { get; } = []; public ObservableCollection<MinintDocument> Documents { get; } = [];
public ObservableCollection<MinintLayer> Layers { get; } = []; public ObservableCollection<MinintLayer> Layers { get; } = [];
#region Container / Document management
public void NewContainer(int width, int height) public void NewContainer(int width, int height)
{ {
var c = new MinintContainer(width, height); var c = new MinintContainer(width, height);
@@ -63,16 +95,10 @@ public partial class EditorViewModel : ViewModelBase
Container = container; Container = container;
FilePath = path; FilePath = path;
Documents.Clear(); SyncDocumentsList();
foreach (var doc in container.Documents)
Documents.Add(doc);
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null); SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
} }
/// <summary>
/// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding).
/// </summary>
partial void OnActiveDocumentChanged(MinintDocument? value) partial void OnActiveDocumentChanged(MinintDocument? value)
{ {
if (_suppressDocumentSync) return; if (_suppressDocumentSync) return;
@@ -84,12 +110,26 @@ public partial class EditorViewModel : ViewModelBase
_suppressDocumentSync = true; _suppressDocumentSync = true;
ActiveDocument = doc; ActiveDocument = doc;
_suppressDocumentSync = false; _suppressDocumentSync = false;
SyncLayersAndCanvas(doc); SyncLayersAndCanvas(doc);
} }
/// <summary>
/// Re-syncs the Documents observable collection after an external modification
/// to Container.Documents (e.g. pattern generation adding a document).
/// </summary>
public void SyncAfterExternalChange() => SyncDocumentsList();
private void SyncDocumentsList()
{
Documents.Clear();
if (Container is null) return;
foreach (var doc in Container.Documents)
Documents.Add(doc);
}
private void SyncLayersAndCanvas(MinintDocument? doc) private void SyncLayersAndCanvas(MinintDocument? doc)
{ {
UnsubscribeLayerVisibility();
Layers.Clear(); Layers.Clear();
if (doc is not null) if (doc is not null)
{ {
@@ -101,10 +141,268 @@ public partial class EditorViewModel : ViewModelBase
{ {
ActiveLayer = null; ActiveLayer = null;
} }
SubscribeLayerVisibility();
RefreshCanvas();
}
#endregion
#region Layer visibility change tracking
private void SubscribeLayerVisibility()
{
foreach (var layer in Layers)
{
if (layer is INotifyPropertyChanged npc)
npc.PropertyChanged += OnLayerPropertyChanged;
}
}
private void UnsubscribeLayerVisibility()
{
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(); 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() public void RefreshCanvas()
{ {
if (Container is null || ActiveDocument is null) if (Container is null || ActiveDocument is null)
@@ -129,7 +427,6 @@ public partial class EditorViewModel : ViewModelBase
var dst = new Span<uint>((void*)fb.Address, w * h); var dst = new Span<uint>((void*)fb.Address, w * h);
for (int i = 0; i < argb.Length; i++) for (int i = 0; i < argb.Length; i++)
{ {
// argb[i] is 0xAARRGGBB, need premultiplied BGRA for the bitmap
uint px = argb[i]; uint px = argb[i];
byte a = (byte)(px >> 24); byte a = (byte)(px >> 24);
byte r = (byte)((px >> 16) & 0xFF); byte r = (byte)((px >> 16) & 0xFF);
@@ -138,7 +435,7 @@ public partial class EditorViewModel : ViewModelBase
if (a == 255) if (a == 255)
{ {
dst[i] = px; // ARGB layout == BGRA in LE memory, alpha=255 → no premul needed dst[i] = px;
} }
else if (a == 0) else if (a == 0)
{ {
@@ -158,6 +455,10 @@ public partial class EditorViewModel : ViewModelBase
CanvasBitmap = bmp; CanvasBitmap = bmp;
} }
#endregion
public ICompositor Compositor => _compositor; public ICompositor Compositor => _compositor;
public IPaletteService PaletteService => _paletteService; public IPaletteService PaletteService => _paletteService;
public IDrawingService DrawingService => _drawingService;
public IFragmentService FragmentService => _fragmentService;
} }

View File

@@ -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,14 +77,10 @@ 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]
private async Task SaveFileAsAsync() private async Task SaveFileAsAsync()
@@ -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
} }

View File

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

View File

@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace Minint.ViewModels;
/// <summary>
/// Static IValueConverter instances for binding RadioButton.IsChecked to ToolType.
/// These are one-way (read-only) — the RadioButton Command sets the actual value.
/// </summary>
public static class ToolTypeConverters
{
public static readonly IValueConverter IsBrush = new ToolTypeConverter(ToolType.Brush);
public static readonly IValueConverter IsEraser = new ToolTypeConverter(ToolType.Eraser);
public static readonly IValueConverter IsFill = new ToolTypeConverter(ToolType.Fill);
private sealed class ToolTypeConverter(ToolType target) : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is ToolType t && t == target;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> target;
}
}

View File

@@ -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>

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

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

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

View File

@@ -9,7 +9,8 @@
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> <Design.DataContext>
<vm:MainWindowViewModel/> <vm:MainWindowViewModel/>
@@ -25,12 +26,62 @@
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/> <MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/> <MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Edit">
<MenuItem Header="Copy _Fragment…" Command="{Binding CopyFragmentCommand}"
ToolTip.Tip="Copy a rectangular region between documents"/>
</MenuItem>
<MenuItem Header="_Image">
<MenuItem Header="Adjust _Contrast…" Command="{Binding ApplyContrastCommand}"
ToolTip.Tip="Adjust contrast of the active document's palette"/>
<MenuItem Header="Convert to _Grayscale" Command="{Binding ApplyGrayscaleCommand}"
ToolTip.Tip="Convert active document to grayscale"/>
<Separator/>
<MenuItem Header="Generate _Pattern…" Command="{Binding GeneratePatternCommand}"
ToolTip.Tip="Generate a new document with a parametric pattern"/>
</MenuItem>
<MenuItem Header="_View"> <MenuItem Header="_View">
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox" <MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/> IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
</MenuItem> </MenuItem>
</Menu> </Menu>
<!-- Toolbar -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="0,0,0,1" Padding="6,4">
<StackPanel Orientation="Horizontal" Spacing="10">
<RadioButton GroupName="Tool" Content="Brush"
ToolTip.Tip="Brush tool — draw with selected color"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsBrush}}"
Command="{Binding Editor.SelectBrushCommand}"/>
<RadioButton GroupName="Tool" Content="Eraser"
ToolTip.Tip="Eraser tool — erase to transparent"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsEraser}}"
Command="{Binding Editor.SelectEraserCommand}"/>
<RadioButton GroupName="Tool" Content="Fill"
ToolTip.Tip="Fill tool — flood fill with selected color"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
Command="{Binding Editor.SelectFillCommand}"/>
<Separator/>
<TextBlock Text="Size:" VerticalAlignment="Center"/>
<Slider Value="{Binding Editor.BrushRadius}" Minimum="0" Maximum="64"
TickFrequency="1" IsSnapToTickEnabled="True" Width="120"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Editor.BrushRadius}" VerticalAlignment="Center"
Width="20" TextAlignment="Center"/>
<Separator/>
<TextBlock Text="Color:" VerticalAlignment="Center"/>
<ColorPicker x:Name="ToolColorPicker"
Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
IsAlphaVisible="False"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Status bar --> <!-- Status bar -->
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}" <Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
Padding="8,2"> Padding="8,2">
@@ -38,19 +89,29 @@
</Border> </Border>
<!-- Main content: left panel, canvas, right panel --> <!-- Main content: left panel, canvas, right panel -->
<Grid ColumnDefinitions="180,*,180"> <Grid ColumnDefinitions="200,*,200">
<!-- Left panel: documents --> <!-- Left panel: documents -->
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" <Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="0,0,1,0" Padding="4"> BorderThickness="0,0,1,0" Padding="4">
<DockPanel> <DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/> <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}" <ListBox ItemsSource="{Binding Editor.Documents}"
SelectedItem="{Binding Editor.ActiveDocument}" SelectedItem="{Binding Editor.ActiveDocument}"
SelectionMode="Single"> SelectionMode="Single">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Name}"/> <controls:EditableTextBlock Text="{Binding Name}"/>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
@@ -75,14 +136,29 @@
BorderThickness="1,0,0,0" Padding="4"> BorderThickness="1,0,0,0" Padding="4">
<DockPanel> <DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/> <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}" <ListBox ItemsSource="{Binding Editor.Layers}"
SelectedItem="{Binding Editor.ActiveLayer}" SelectedItem="{Binding Editor.ActiveLayer}"
SelectionMode="Single"> SelectionMode="Single">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"/> <CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/> VerticalAlignment="Center"
ToolTip.Tip="Toggle layer visibility"/>
<controls:EditableTextBlock Text="{Binding Name}"
VerticalAlignment="Center" MinWidth="60"/>
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>

View File

@@ -23,6 +23,9 @@ public partial class MainWindow : Window
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)
WireCanvasEvents(canvas, vm.Editor);
} }
protected override void OnDataContextChanged(EventArgs e) protected override void OnDataContextChanged(EventArgs e)
@@ -31,4 +34,12 @@ public partial class MainWindow : Window
if (DataContext is MainWindowViewModel vm) if (DataContext is MainWindowViewModel vm)
vm.Owner = this; 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();
}
} }

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

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