From e7546f9d129b9e854d850b9519f6d6e33f6788dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 29 Mar 2026 17:53:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Minint/Controls/PixelCanvas.cs | 178 +++++++++++++++++--- Minint/ViewModels/EditorViewModel.cs | 206 +++++++++++++++++++---- Minint/ViewModels/MainWindowViewModel.cs | 44 ----- Minint/ViewModels/ToolType.cs | 3 +- Minint/ViewModels/ToolTypeConverters.cs | 1 + Minint/Views/CopyFragmentDialog.axaml | 51 ------ Minint/Views/CopyFragmentDialog.axaml.cs | 91 ---------- Minint/Views/MainWindow.axaml | 12 +- Minint/Views/MainWindow.axaml.cs | 10 ++ 9 files changed, 354 insertions(+), 242 deletions(-) delete mode 100644 Minint/Views/CopyFragmentDialog.axaml delete mode 100644 Minint/Views/CopyFragmentDialog.axaml.cs diff --git a/Minint/Controls/PixelCanvas.cs b/Minint/Controls/PixelCanvas.cs index 9ca6b70..b9b2e0d 100644 --- a/Minint/Controls/PixelCanvas.cs +++ b/Minint/Controls/PixelCanvas.cs @@ -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 - /// Fires when the user presses the left mouse button at an image pixel. public event Action? ToolDown; - - /// Fires when the user drags with left button held at an image pixel. public event Action? ToolDrag; - - /// Fires when the cursor moves over the image (pixel coords, or null if outside). public event Action<(int X, int Y)?>? CursorPixelChanged; - - /// Set by the host to provide preview mask pixels for overlay. public Func?>? GetPreviewMask { get; set; } + // Selection events + public event Action? SelectionStart; + public event Action? SelectionUpdate; + public event Action? SelectionEnd; + + // Paste events + public event Action? PasteMoved; + public event Action? PasteCommitted; + public event Action? PasteCancelled; + + /// Provides the current EditorViewModel for reading selection/paste state during render. + 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); } - /// - /// Recalculates the pixel coordinate under the cursor after a viewport change. - /// 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) diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs index 42c91be..af41da5 100644 --- a/Minint/ViewModels/EditorViewModel.cs +++ b/Minint/ViewModels/EditorViewModel.cs @@ -13,6 +13,11 @@ using Minint.Core.Services.Impl; namespace Minint.ViewModels; +/// +/// Palette-independent clipboard fragment: stores resolved RGBA pixels. +/// +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); } - /// - /// Re-syncs the Documents observable collection after an external modification - /// to Container.Documents (e.g. pattern generation adding a document). - /// 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) - /// - /// Copies a rectangular fragment from active layer - /// to active layer. - /// - public void CopyFragment( - MinintDocument srcDoc, int srcLayerIndex, - int srcX, int srcY, int regionW, int regionH, - MinintDocument dstDoc, int dstLayerIndex, - int dstX, int dstY) + /// Called by PixelCanvas when selection drag starts. + 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); + } + + /// Called by PixelCanvas as the user drags. + 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); + } + + /// Called by PixelCanvas when mouse is released. + 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; + + /// The normalized (positive W/H, clamped) selection rectangle for rendering. + 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 diff --git a/Minint/ViewModels/MainWindowViewModel.cs b/Minint/ViewModels/MainWindowViewModel.cs index 15fed79..2ce1dde 100644 --- a/Minint/ViewModels/MainWindowViewModel.cs +++ b/Minint/ViewModels/MainWindowViewModel.cs @@ -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(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] diff --git a/Minint/ViewModels/ToolType.cs b/Minint/ViewModels/ToolType.cs index a04b309..1c513e9 100644 --- a/Minint/ViewModels/ToolType.cs +++ b/Minint/ViewModels/ToolType.cs @@ -4,5 +4,6 @@ public enum ToolType { Brush, Eraser, - Fill + Fill, + Select } diff --git a/Minint/ViewModels/ToolTypeConverters.cs b/Minint/ViewModels/ToolTypeConverters.cs index ec1d701..1b50d64 100644 --- a/Minint/ViewModels/ToolTypeConverters.cs +++ b/Minint/ViewModels/ToolTypeConverters.cs @@ -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 { diff --git a/Minint/Views/CopyFragmentDialog.axaml b/Minint/Views/CopyFragmentDialog.axaml deleted file mode 100644 index 6168d92..0000000 --- a/Minint/Views/CopyFragmentDialog.axaml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -