Files
Minint/Minint/ViewModels/EditorViewModel.cs
2026-03-29 18:19:39 +03:00

735 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Minint.Core.Models;
using Minint.Core.Services;
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();
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]
[NotifyPropertyChangedFor(nameof(HasContainer))]
[NotifyPropertyChangedFor(nameof(Title))]
private MinintContainer? _container;
[ObservableProperty]
private MinintDocument? _activeDocument;
[ObservableProperty]
private MinintLayer? _activeLayer;
private bool _suppressDocumentSync;
[ObservableProperty]
private WriteableBitmap? _canvasBitmap;
[ObservableProperty]
private bool _showGrid;
// Tool state
[ObservableProperty]
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);
// 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;
public bool HasContainer => Container is not null;
public string Title => FilePath is not null
? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
: Container is not null
? "Minint — Untitled"
: "Minint";
public ObservableCollection<MinintDocument> Documents { get; } = [];
public ObservableCollection<MinintLayer> Layers { get; } = [];
#region Container / Document management
public void NewContainer(int width, int height)
{
var c = new MinintContainer(width, height);
c.AddNewDocument("Document 1");
LoadContainer(c, null);
}
public void LoadContainer(MinintContainer container, string? path)
{
Container = container;
FilePath = path;
SyncDocumentsList();
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
}
partial void OnActiveDocumentChanged(MinintDocument? value)
{
if (_suppressDocumentSync) return;
SyncLayersAndCanvas(value);
}
public void SelectDocument(MinintDocument? doc)
{
_suppressDocumentSync = true;
ActiveDocument = doc;
_suppressDocumentSync = false;
SyncLayersAndCanvas(doc);
}
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)
{
UnsubscribeLayerVisibility();
Layers.Clear();
if (doc is not null)
{
foreach (var layer in doc.Layers)
Layers.Add(layer);
ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
}
else
{
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;
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() { }
[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;
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 (IsPlaying) return;
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;
if (ActiveTool == ToolType.Select) return; // handled separately
switch (ActiveTool)
{
case ToolType.Brush:
{
int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
_drawingService.ApplyBrush(ActiveLayer, px, py, BrushRadius, colorIdx, w, h);
break;
}
case ToolType.Eraser:
_drawingService.ApplyEraser(ActiveLayer, px, py, BrushRadius, w, h);
break;
case ToolType.Fill:
{
int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
_floodFillService.Fill(ActiveLayer, px, py, colorIdx, w, h);
break;
}
}
RefreshCanvas();
}
public void OnToolDrag(int px, int py)
{
if (IsPlaying) return;
if (ActiveTool is ToolType.Fill or ToolType.Select) return;
OnToolDown(px, py);
}
public List<(int X, int Y)>? GetPreviewMask()
{
if (PreviewCenter is null || Container is null)
return null;
if (ActiveTool is ToolType.Fill or ToolType.Select)
return null;
var (cx, cy) = PreviewCenter.Value;
return _drawingService.GetBrushMask(cx, cy, BrushRadius, Container.Width, Container.Height);
}
[RelayCommand]
private void SelectBrush() { CancelPasteMode(); ActiveTool = ToolType.Brush; }
[RelayCommand]
private void SelectEraser() { CancelPasteMode(); ActiveTool = ToolType.Eraser; }
[RelayCommand]
private void SelectFill() { CancelPasteMode(); ActiveTool = ToolType.Fill; }
[RelayCommand]
private void SelectSelectTool() { CancelPasteMode(); ActiveTool = ToolType.Select; }
#endregion
#region Selection + Copy/Paste (A4)
/// <summary>Called by PixelCanvas when selection drag starts.</summary>
public void BeginSelection(int px, int py)
{
if (IsPlaying) return;
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 (IsPlaying || !IsPasting) return;
PastePosition = (px, py);
}
[RelayCommand]
public void CommitPaste()
{
if (IsPlaying) return;
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
public void RefreshCanvas()
{
if (Container is null || ActiveDocument is null)
{
CanvasBitmap = null;
return;
}
int w = Container.Width;
int h = Container.Height;
uint[] argb = _compositor.Composite(ActiveDocument, w, h);
var bmp = new WriteableBitmap(
new PixelSize(w, h),
new Vector(96, 96),
Avalonia.Platform.PixelFormat.Bgra8888);
using (var fb = bmp.Lock())
{
unsafe
{
var dst = new Span<uint>((void*)fb.Address, w * h);
for (int i = 0; i < argb.Length; i++)
{
uint px = argb[i];
byte a = (byte)(px >> 24);
byte r = (byte)((px >> 16) & 0xFF);
byte g = (byte)((px >> 8) & 0xFF);
byte b = (byte)(px & 0xFF);
if (a == 255)
{
dst[i] = px;
}
else if (a == 0)
{
dst[i] = 0;
}
else
{
r = (byte)(r * a / 255);
g = (byte)(g * a / 255);
b = (byte)(b * a / 255);
dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
}
}
}
}
CanvasBitmap = bmp;
}
#endregion
#region Animation playback
private DispatcherTimer? _animationTimer;
private int _animationFrameIndex;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNotPlaying))]
private bool _isPlaying;
public bool IsNotPlaying => !IsPlaying;
[RelayCommand]
private void PlayAnimation()
{
if (Container is null || Container.Documents.Count < 2) return;
if (IsPlaying) return;
IsPlaying = true;
_animationFrameIndex = ActiveDocument is not null
? Container.Documents.IndexOf(ActiveDocument)
: 0;
if (_animationFrameIndex < 0) _animationFrameIndex = 0;
AdvanceAnimationFrame();
}
[RelayCommand]
private void StopAnimation()
{
_animationTimer?.Stop();
_animationTimer = null;
IsPlaying = false;
}
private void AdvanceAnimationFrame()
{
if (Container is null || !IsPlaying)
{
StopAnimation();
return;
}
var docs = Container.Documents;
if (docs.Count == 0)
{
StopAnimation();
return;
}
_animationFrameIndex %= docs.Count;
var doc = docs[_animationFrameIndex];
_suppressDocumentSync = true;
ActiveDocument = doc;
_suppressDocumentSync = false;
RefreshCanvasFor(doc);
uint delay = doc.FrameDelayMs;
if (delay < 10) delay = 10;
_animationTimer?.Stop();
_animationTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(delay) };
_animationTimer.Tick += (_, _) =>
{
_animationTimer?.Stop();
_animationFrameIndex++;
AdvanceAnimationFrame();
};
_animationTimer.Start();
}
private void RefreshCanvasFor(MinintDocument doc)
{
if (Container is null)
{
CanvasBitmap = null;
return;
}
int w = Container.Width;
int h = Container.Height;
uint[] argb = _compositor.Composite(doc, w, h);
var bmp = new WriteableBitmap(
new PixelSize(w, h),
new Vector(96, 96),
Avalonia.Platform.PixelFormat.Bgra8888);
using (var fb = bmp.Lock())
{
unsafe
{
var dst = new Span<uint>((void*)fb.Address, w * h);
for (int i = 0; i < argb.Length; i++)
{
uint px = argb[i];
byte a2 = (byte)(px >> 24);
byte r2 = (byte)((px >> 16) & 0xFF);
byte g2 = (byte)((px >> 8) & 0xFF);
byte b2 = (byte)(px & 0xFF);
if (a2 == 255) { dst[i] = px; }
else if (a2 == 0) { dst[i] = 0; }
else
{
r2 = (byte)(r2 * a2 / 255);
g2 = (byte)(g2 * a2 / 255);
b2 = (byte)(b2 * a2 / 255);
dst[i] = (uint)(b2 | (g2 << 8) | (r2 << 16) | (a2 << 24));
}
}
}
}
CanvasBitmap = bmp;
}
#endregion
public ICompositor Compositor => _compositor;
public IPaletteService PaletteService => _paletteService;
public IDrawingService DrawingService => _drawingService;
public IFragmentService FragmentService => _fragmentService;
}