Выделение и вставка
This commit is contained in:
@@ -7,6 +7,8 @@ using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Minint.Core.Models;
|
||||
using Minint.ViewModels;
|
||||
|
||||
namespace Minint.Controls;
|
||||
|
||||
@@ -36,23 +38,30 @@ public class PixelCanvas : Control
|
||||
|
||||
#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; }
|
||||
|
||||
// Selection events
|
||||
public event Action<int, int>? SelectionStart;
|
||||
public event Action<int, int>? SelectionUpdate;
|
||||
public event Action<int, int>? SelectionEnd;
|
||||
|
||||
// Paste events
|
||||
public event Action<int, int>? PasteMoved;
|
||||
public event Action? PasteCommitted;
|
||||
public event Action? PasteCancelled;
|
||||
|
||||
/// <summary>Provides the current EditorViewModel for reading selection/paste state during render.</summary>
|
||||
public EditorViewModel? Editor { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
private readonly Viewport _viewport = new();
|
||||
private bool _isPanning;
|
||||
private bool _isDrawing;
|
||||
private bool _isSelecting;
|
||||
private Point _panStart;
|
||||
private double _panStartOffsetX, _panStartOffsetY;
|
||||
private bool _viewportInitialized;
|
||||
@@ -120,6 +129,8 @@ public class PixelCanvas : Control
|
||||
DrawPixelGrid(context, imgW, imgH);
|
||||
|
||||
DrawToolPreview(context, imgW, imgH);
|
||||
DrawSelectionOverlay(context);
|
||||
DrawPastePreview(context);
|
||||
|
||||
int w = imgW, h = imgH;
|
||||
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
|
||||
@@ -199,11 +210,9 @@ public class PixelCanvas : Control
|
||||
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);
|
||||
context.FillRectangle(previewBrush, new Rect(sx, sy, zoom, zoom));
|
||||
}
|
||||
|
||||
// Outline around the mask bounding box
|
||||
if (mask.Count > 0)
|
||||
{
|
||||
int minX = mask[0].X, maxX = mask[0].X;
|
||||
@@ -216,12 +225,63 @@ public class PixelCanvas : Control
|
||||
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);
|
||||
context.DrawRectangle(outlinePen, new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelectionOverlay(DrawingContext context)
|
||||
{
|
||||
var sel = Editor?.SelectionRectNormalized;
|
||||
if (sel is null) return;
|
||||
|
||||
var (sx, sy, sw, sh) = sel.Value;
|
||||
double zoom = _viewport.Zoom;
|
||||
var (screenX, screenY) = _viewport.PixelToScreen(sx, sy);
|
||||
var rect = new Rect(screenX, screenY, sw * zoom, sh * zoom);
|
||||
|
||||
var fillBrush = new SolidColorBrush(Color.FromArgb(40, 100, 150, 255));
|
||||
context.FillRectangle(fillBrush, rect);
|
||||
|
||||
var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(200, 100, 150, 255)), 1,
|
||||
new DashStyle([4, 4], 0));
|
||||
context.DrawRectangle(borderPen, rect);
|
||||
}
|
||||
|
||||
private void DrawPastePreview(DrawingContext context)
|
||||
{
|
||||
if (Editor is null || !Editor.IsPasting || Editor.Clipboard is null)
|
||||
return;
|
||||
|
||||
var pos = Editor.PastePosition!.Value;
|
||||
var frag = Editor.Clipboard;
|
||||
double zoom = _viewport.Zoom;
|
||||
|
||||
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||
using (context.PushClip(clip))
|
||||
{
|
||||
for (int fy = 0; fy < frag.Height; fy++)
|
||||
{
|
||||
for (int fx = 0; fx < frag.Width; fx++)
|
||||
{
|
||||
var color = frag.Pixels[fy * frag.Width + fx];
|
||||
if (color.A == 0) continue;
|
||||
|
||||
var (sx, sy) = _viewport.PixelToScreen(pos.X + fx, pos.Y + fy);
|
||||
byte dispA = (byte)(color.A * 180 / 255); // semi-transparent preview
|
||||
var brush = new SolidColorBrush(Color.FromArgb(dispA, color.R, color.G, color.B));
|
||||
context.FillRectangle(brush, new Rect(sx, sy, zoom, zoom));
|
||||
}
|
||||
}
|
||||
|
||||
// Border around the floating fragment
|
||||
var (ox, oy) = _viewport.PixelToScreen(pos.X, pos.Y);
|
||||
var borderPen = new Pen(new SolidColorBrush(Color.FromArgb(200, 255, 200, 50)), 1,
|
||||
new DashStyle([3, 3], 0));
|
||||
context.DrawRectangle(borderPen, new Rect(ox, oy, frag.Width * zoom, frag.Height * zoom));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scrollbar Sync
|
||||
@@ -284,9 +344,6 @@ public class PixelCanvas : Control
|
||||
return (px, py);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recalculates the pixel coordinate under the cursor after a viewport change.
|
||||
/// </summary>
|
||||
private void RecalcCursorPixel()
|
||||
{
|
||||
if (_lastScreenPos is null) return;
|
||||
@@ -341,17 +398,35 @@ public class PixelCanvas : Control
|
||||
_panStartOffsetX = _viewport.OffsetX;
|
||||
_panStartOffsetY = _viewport.OffsetY;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
else if (props.IsLeftButtonPressed && !_isPanning)
|
||||
|
||||
if (!props.IsLeftButtonPressed || _isPanning) return;
|
||||
|
||||
var pixel = ScreenToPixelClamped(e.GetPosition(this));
|
||||
if (pixel is null) return;
|
||||
|
||||
// Paste mode: left-click commits
|
||||
if (Editor is not null && Editor.IsPasting)
|
||||
{
|
||||
var pixel = ScreenToPixelClamped(e.GetPosition(this));
|
||||
if (pixel is not null)
|
||||
{
|
||||
_isDrawing = true;
|
||||
ToolDown?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
e.Handled = true;
|
||||
}
|
||||
PasteCommitted?.Invoke();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Select tool: begin rubber-band
|
||||
if (Editor is not null && Editor.ActiveTool == ToolType.Select)
|
||||
{
|
||||
_isSelecting = true;
|
||||
SelectionStart?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular drawing tools
|
||||
_isDrawing = true;
|
||||
ToolDown?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
@@ -378,7 +453,24 @@ public class PixelCanvas : Control
|
||||
{
|
||||
_lastCursorPixel = pixel;
|
||||
CursorPixelChanged?.Invoke(pixel);
|
||||
}
|
||||
|
||||
// Paste mode: floating fragment follows cursor
|
||||
if (Editor is not null && Editor.IsPasting && pixel is not null)
|
||||
{
|
||||
PasteMoved?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection rubber-band drag
|
||||
if (_isSelecting && pixel is not null)
|
||||
{
|
||||
SelectionUpdate?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDrawing && pixel is not null)
|
||||
@@ -386,6 +478,10 @@ public class PixelCanvas : Control
|
||||
ToolDrag?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
e.Handled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
@@ -397,6 +493,15 @@ public class PixelCanvas : Control
|
||||
_isPanning = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (_isSelecting && e.InitialPressMouseButton == MouseButton.Left)
|
||||
{
|
||||
_isSelecting = false;
|
||||
var pixel = ScreenToPixelClamped(e.GetPosition(this));
|
||||
if (pixel is not null)
|
||||
SelectionEnd?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (_isDrawing && e.InitialPressMouseButton == MouseButton.Left)
|
||||
{
|
||||
_isDrawing = false;
|
||||
@@ -416,6 +521,33 @@ public class PixelCanvas : Control
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
if (Editor is not null && Editor.IsPasting)
|
||||
{
|
||||
PasteCancelled?.Invoke();
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (Editor is not null && Editor.HasSelection)
|
||||
{
|
||||
Editor.ClearSelection();
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
else if (e.Key == Key.Enter && Editor is not null && Editor.IsPasting)
|
||||
{
|
||||
PasteCommitted?.Invoke();
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
|
||||
@@ -13,6 +13,11 @@ using Minint.Core.Services.Impl;
|
||||
|
||||
namespace Minint.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Palette-independent clipboard fragment: stores resolved RGBA pixels.
|
||||
/// </summary>
|
||||
public sealed record ClipboardFragment(int Width, int Height, RgbaColor[] Pixels);
|
||||
|
||||
public partial class EditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ICompositor _compositor = new Compositor();
|
||||
@@ -66,6 +71,27 @@ public partial class EditorViewModel : ViewModelBase
|
||||
|
||||
public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
|
||||
|
||||
// Selection state (Select tool rubber-band)
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasSelection))]
|
||||
private (int X, int Y, int W, int H)? _selectionRect;
|
||||
|
||||
public bool HasSelection => SelectionRect is not null;
|
||||
|
||||
// Clipboard
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasClipboard))]
|
||||
private ClipboardFragment? _clipboard;
|
||||
|
||||
public bool HasClipboard => Clipboard is not null;
|
||||
|
||||
// Paste mode
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPasting))]
|
||||
private (int X, int Y)? _pastePosition;
|
||||
|
||||
public bool IsPasting => PastePosition is not null;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Title))]
|
||||
private string? _filePath;
|
||||
@@ -113,10 +139,6 @@ public partial class EditorViewModel : ViewModelBase
|
||||
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()
|
||||
@@ -191,7 +213,7 @@ public partial class EditorViewModel : ViewModelBase
|
||||
private void RemoveDocument()
|
||||
{
|
||||
if (Container is null || ActiveDocument is null) return;
|
||||
if (Container.Documents.Count <= 1) return; // keep at least one
|
||||
if (Container.Documents.Count <= 1) return;
|
||||
|
||||
var doc = ActiveDocument;
|
||||
int idx = Container.Documents.IndexOf(doc);
|
||||
@@ -203,10 +225,7 @@ public partial class EditorViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RenameDocument()
|
||||
{
|
||||
// Triggered via UI text edit — the Name property is directly editable via TextBox
|
||||
}
|
||||
private void RenameDocument() { }
|
||||
|
||||
[RelayCommand]
|
||||
private void MoveDocumentUp()
|
||||
@@ -254,7 +273,7 @@ public partial class EditorViewModel : ViewModelBase
|
||||
private void RemoveLayer()
|
||||
{
|
||||
if (ActiveDocument is null || ActiveLayer is null) return;
|
||||
if (ActiveDocument.Layers.Count <= 1) return; // keep at least one
|
||||
if (ActiveDocument.Layers.Count <= 1) return;
|
||||
|
||||
UnsubscribeLayerVisibility();
|
||||
var layer = ActiveLayer;
|
||||
@@ -329,6 +348,8 @@ public partial class EditorViewModel : ViewModelBase
|
||||
if (px < 0 || px >= w || py < 0 || py >= h)
|
||||
return;
|
||||
|
||||
if (ActiveTool == ToolType.Select) return; // handled separately
|
||||
|
||||
switch (ActiveTool)
|
||||
{
|
||||
case ToolType.Brush:
|
||||
@@ -353,7 +374,7 @@ public partial class EditorViewModel : ViewModelBase
|
||||
|
||||
public void OnToolDrag(int px, int py)
|
||||
{
|
||||
if (ActiveTool == ToolType.Fill) return;
|
||||
if (ActiveTool is ToolType.Fill or ToolType.Select) return;
|
||||
OnToolDown(px, py);
|
||||
}
|
||||
|
||||
@@ -361,7 +382,7 @@ public partial class EditorViewModel : ViewModelBase
|
||||
{
|
||||
if (PreviewCenter is null || Container is null)
|
||||
return null;
|
||||
if (ActiveTool == ToolType.Fill)
|
||||
if (ActiveTool is ToolType.Fill or ToolType.Select)
|
||||
return null;
|
||||
|
||||
var (cx, cy) = PreviewCenter.Value;
|
||||
@@ -369,36 +390,161 @@ public partial class EditorViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectBrush() => ActiveTool = ToolType.Brush;
|
||||
private void SelectBrush() { CancelPasteMode(); ActiveTool = ToolType.Brush; }
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectEraser() => ActiveTool = ToolType.Eraser;
|
||||
private void SelectEraser() { CancelPasteMode(); ActiveTool = ToolType.Eraser; }
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectFill() => ActiveTool = ToolType.Fill;
|
||||
private void SelectFill() { CancelPasteMode(); ActiveTool = ToolType.Fill; }
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectSelectTool() { CancelPasteMode(); ActiveTool = ToolType.Select; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fragment copy (A4)
|
||||
#region Selection + Copy/Paste (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)
|
||||
/// <summary>Called by PixelCanvas when selection drag starts.</summary>
|
||||
public void BeginSelection(int px, int py)
|
||||
{
|
||||
if (Container is null) return;
|
||||
_fragmentService.CopyFragment(
|
||||
srcDoc, srcLayerIndex, srcX, srcY, regionW, regionH,
|
||||
dstDoc, dstLayerIndex, dstX, dstY,
|
||||
Container.Width, Container.Height);
|
||||
SelectionRect = (px, py, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>Called by PixelCanvas as the user drags.</summary>
|
||||
public void UpdateSelection(int px, int py)
|
||||
{
|
||||
if (SelectionRect is null) return;
|
||||
var s = SelectionRect.Value;
|
||||
int x = Math.Min(s.X, px);
|
||||
int y = Math.Min(s.Y, py);
|
||||
int w = Math.Abs(px - s.X) + 1;
|
||||
int h = Math.Abs(py - s.Y) + 1;
|
||||
// Store normalized rect but keep original anchor in _selAnchor
|
||||
_selectionRectNormalized = (x, y, w, h);
|
||||
}
|
||||
|
||||
/// <summary>Called by PixelCanvas when mouse is released.</summary>
|
||||
public void FinishSelection(int px, int py)
|
||||
{
|
||||
if (SelectionRect is null) return;
|
||||
var s = SelectionRect.Value;
|
||||
int x0 = Math.Min(s.X, px);
|
||||
int y0 = Math.Min(s.Y, py);
|
||||
int rw = Math.Abs(px - s.X) + 1;
|
||||
int rh = Math.Abs(py - s.Y) + 1;
|
||||
if (Container is not null)
|
||||
{
|
||||
x0 = Math.Max(0, x0);
|
||||
y0 = Math.Max(0, y0);
|
||||
rw = Math.Min(rw, Container.Width - x0);
|
||||
rh = Math.Min(rh, Container.Height - y0);
|
||||
}
|
||||
if (rw <= 0 || rh <= 0)
|
||||
{
|
||||
SelectionRect = null;
|
||||
_selectionRectNormalized = null;
|
||||
return;
|
||||
}
|
||||
SelectionRect = (x0, y0, rw, rh);
|
||||
_selectionRectNormalized = SelectionRect;
|
||||
}
|
||||
|
||||
private (int X, int Y, int W, int H)? _selectionRectNormalized;
|
||||
|
||||
/// <summary>The normalized (positive W/H, clamped) selection rectangle for rendering.</summary>
|
||||
public (int X, int Y, int W, int H)? SelectionRectNormalized => _selectionRectNormalized;
|
||||
|
||||
[RelayCommand]
|
||||
private void CopySelection()
|
||||
{
|
||||
if (SelectionRect is null || ActiveDocument is null || ActiveLayer is null || Container is null)
|
||||
return;
|
||||
|
||||
var (sx, sy, sw, sh) = SelectionRect.Value;
|
||||
int cw = Container.Width;
|
||||
var palette = ActiveDocument.Palette;
|
||||
var srcPixels = ActiveLayer.Pixels;
|
||||
var buf = new RgbaColor[sw * sh];
|
||||
|
||||
for (int dy = 0; dy < sh; dy++)
|
||||
{
|
||||
int srcRow = sy + dy;
|
||||
for (int dx = 0; dx < sw; dx++)
|
||||
{
|
||||
int srcCol = sx + dx;
|
||||
int idx = srcPixels[srcRow * cw + srcCol];
|
||||
buf[dy * sw + dx] = idx < palette.Count ? palette[idx] : RgbaColor.Transparent;
|
||||
}
|
||||
}
|
||||
|
||||
Clipboard = new ClipboardFragment(sw, sh, buf);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void PasteClipboard()
|
||||
{
|
||||
if (Clipboard is null) return;
|
||||
PastePosition = (0, 0);
|
||||
}
|
||||
|
||||
public void MovePaste(int px, int py)
|
||||
{
|
||||
if (!IsPasting) return;
|
||||
PastePosition = (px, py);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void CommitPaste()
|
||||
{
|
||||
if (!IsPasting || Clipboard is null || ActiveDocument is null || ActiveLayer is null || Container is null)
|
||||
return;
|
||||
|
||||
var (px, py) = PastePosition!.Value;
|
||||
int cw = Container.Width, ch = Container.Height;
|
||||
var frag = Clipboard;
|
||||
var dstPixels = ActiveLayer.Pixels;
|
||||
|
||||
for (int fy = 0; fy < frag.Height; fy++)
|
||||
{
|
||||
int dy = py + fy;
|
||||
if (dy < 0 || dy >= ch) continue;
|
||||
for (int fx = 0; fx < frag.Width; fx++)
|
||||
{
|
||||
int dx = px + fx;
|
||||
if (dx < 0 || dx >= cw) continue;
|
||||
var color = frag.Pixels[fy * frag.Width + fx];
|
||||
if (color.A == 0) continue; // skip transparent
|
||||
int colorIdx = ActiveDocument.EnsureColorCached(color);
|
||||
dstPixels[dy * cw + dx] = colorIdx;
|
||||
}
|
||||
}
|
||||
|
||||
PastePosition = null;
|
||||
SelectionRect = null;
|
||||
_selectionRectNormalized = null;
|
||||
RefreshCanvas();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void CancelPaste()
|
||||
{
|
||||
PastePosition = null;
|
||||
}
|
||||
|
||||
public void ClearSelection()
|
||||
{
|
||||
SelectionRect = null;
|
||||
_selectionRectNormalized = null;
|
||||
}
|
||||
|
||||
private void CancelPasteMode()
|
||||
{
|
||||
PastePosition = null;
|
||||
SelectionRect = null;
|
||||
_selectionRectNormalized = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Canvas rendering
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
@@ -153,49 +152,6 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
#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]
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum ToolType
|
||||
{
|
||||
Brush,
|
||||
Eraser,
|
||||
Fill
|
||||
Fill,
|
||||
Select
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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);
|
||||
public static readonly IValueConverter IsSelect = new ToolTypeConverter(ToolType.Select);
|
||||
|
||||
private sealed class ToolTypeConverter(ToolType target) : IValueConverter
|
||||
{
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<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>
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,12 @@
|
||||
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Edit">
|
||||
<MenuItem Header="Copy _Fragment…" Command="{Binding CopyFragmentCommand}"
|
||||
ToolTip.Tip="Copy a rectangular region between documents"/>
|
||||
<MenuItem Header="_Copy" Command="{Binding Editor.CopySelectionCommand}"
|
||||
HotKey="Ctrl+C"
|
||||
ToolTip.Tip="Copy the selected region to clipboard"/>
|
||||
<MenuItem Header="_Paste" Command="{Binding Editor.PasteClipboardCommand}"
|
||||
HotKey="Ctrl+V"
|
||||
ToolTip.Tip="Paste clipboard fragment onto the canvas"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Image">
|
||||
<MenuItem Header="Adjust _Contrast…" Command="{Binding ApplyContrastCommand}"
|
||||
@@ -62,6 +66,10 @@
|
||||
ToolTip.Tip="Fill tool — flood fill with selected color"
|
||||
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
|
||||
Command="{Binding Editor.SelectFillCommand}"/>
|
||||
<RadioButton GroupName="Tool" Content="Select"
|
||||
ToolTip.Tip="Select tool — drag to select a region, then Copy/Paste"
|
||||
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsSelect}}"
|
||||
Command="{Binding Editor.SelectSelectToolCommand}"/>
|
||||
|
||||
<Separator/>
|
||||
|
||||
|
||||
@@ -37,9 +37,19 @@ public partial class MainWindow : Window
|
||||
|
||||
private static void WireCanvasEvents(PixelCanvas canvas, EditorViewModel editor)
|
||||
{
|
||||
canvas.Editor = 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();
|
||||
|
||||
canvas.SelectionStart += (px, py) => editor.BeginSelection(px, py);
|
||||
canvas.SelectionUpdate += (px, py) => editor.UpdateSelection(px, py);
|
||||
canvas.SelectionEnd += (px, py) => { editor.FinishSelection(px, py); canvas.InvalidateVisual(); };
|
||||
|
||||
canvas.PasteMoved += (px, py) => editor.MovePaste(px, py);
|
||||
canvas.PasteCommitted += () => { editor.CommitPaste(); canvas.InvalidateVisual(); };
|
||||
canvas.PasteCancelled += () => { editor.CancelPaste(); canvas.InvalidateVisual(); };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user