diff --git a/Minint.Core/Models/MinintLayer.cs b/Minint.Core/Models/MinintLayer.cs
index f767795..098dfe1 100644
--- a/Minint.Core/Models/MinintLayer.cs
+++ b/Minint.Core/Models/MinintLayer.cs
@@ -1,19 +1,39 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
namespace Minint.Core.Models;
///
/// A single raster layer. Pixels are indices into the parent document's palette.
/// Array layout is row-major: Pixels[y * width + x].
///
-public sealed class MinintLayer
+public sealed class MinintLayer : INotifyPropertyChanged
{
- public string Name { get; set; }
- public bool IsVisible { get; set; }
+ private string _name;
+ 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(); } }
+ }
///
/// Per-layer opacity (0 = fully transparent, 255 = fully opaque).
/// Used during compositing: effective alpha = paletteColor.A * Opacity / 255.
///
- public byte Opacity { get; set; }
+ public byte Opacity
+ {
+ get => _opacity;
+ set { if (_opacity != value) { _opacity = value; Notify(); } }
+ }
///
/// Palette indices, length must equal container Width * Height.
@@ -23,9 +43,9 @@ public sealed class MinintLayer
public MinintLayer(string name, int pixelCount)
{
- Name = name;
- IsVisible = true;
- Opacity = 255;
+ _name = name;
+ _isVisible = true;
+ _opacity = 255;
Pixels = new int[pixelCount];
}
@@ -34,9 +54,13 @@ public sealed class MinintLayer
///
public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels)
{
- Name = name;
- IsVisible = isVisible;
- Opacity = opacity;
+ _name = name;
+ _isVisible = isVisible;
+ _opacity = opacity;
Pixels = pixels;
}
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void Notify([CallerMemberName] string? prop = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
}
diff --git a/Minint.Core/Services/Impl/FragmentService.cs b/Minint.Core/Services/Impl/FragmentService.cs
new file mode 100644
index 0000000..eed0f07
--- /dev/null
+++ b/Minint.Core/Services/Impl/FragmentService.cs
@@ -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;
+ }
+ }
+ }
+}
diff --git a/Minint/Controls/EditableTextBlock.cs b/Minint/Controls/EditableTextBlock.cs
new file mode 100644
index 0000000..f5808ea
--- /dev/null
+++ b/Minint/Controls/EditableTextBlock.cs
@@ -0,0 +1,129 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace Minint.Controls;
+
+///
+/// Shows a TextBlock by default; switches to an inline TextBox on double-click.
+/// Commits on Enter or focus loss, cancels on Escape.
+///
+public class EditableTextBlock : Control
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(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;
+ }
+}
diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs
index 5ea0e4f..dd21276 100644
--- a/Minint/ViewModels/EditorViewModel.cs
+++ b/Minint/ViewModels/EditorViewModel.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
using Avalonia;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -17,6 +19,7 @@ public partial class EditorViewModel : ViewModelBase
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))]
@@ -44,17 +47,11 @@ public partial class EditorViewModel : ViewModelBase
[ObservableProperty]
private int _brushRadius = 1;
- ///
- /// Pixel coordinates of current brush/eraser preview center, or null if cursor is outside image.
- ///
[ObservableProperty]
private (int X, int Y)? _previewCenter;
private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
- ///
- /// Avalonia Color bound two-way to the ColorPicker in the toolbar.
- ///
public Avalonia.Media.Color PreviewColor
{
get => _previewColor;
@@ -98,10 +95,7 @@ public partial class EditorViewModel : ViewModelBase
Container = container;
FilePath = path;
- Documents.Clear();
- foreach (var doc in container.Documents)
- Documents.Add(doc);
-
+ SyncDocumentsList();
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
}
@@ -119,8 +113,17 @@ public partial class EditorViewModel : ViewModelBase
SyncLayersAndCanvas(doc);
}
+ 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)
{
@@ -132,6 +135,178 @@ public partial class EditorViewModel : ViewModelBase
{
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();
}
@@ -139,9 +314,6 @@ public partial class EditorViewModel : ViewModelBase
#region Drawing
- ///
- /// Called by PixelCanvas on left-click at the given image pixel coordinate.
- ///
public void OnToolDown(int px, int py)
{
if (Container is null || ActiveDocument is null || ActiveLayer is null)
@@ -173,19 +345,12 @@ public partial class EditorViewModel : ViewModelBase
RefreshCanvas();
}
- ///
- /// Called by PixelCanvas on left-drag at the given image pixel coordinate.
- /// Same as OnToolDown for brush/eraser, no-op for fill.
- ///
public void OnToolDrag(int px, int py)
{
if (ActiveTool == ToolType.Fill) return;
OnToolDown(px, py);
}
- ///
- /// Returns brush mask pixels for tool preview overlay.
- ///
public List<(int X, int Y)>? GetPreviewMask()
{
if (PreviewCenter is null || Container is null)
@@ -208,6 +373,28 @@ public partial class EditorViewModel : ViewModelBase
#endregion
+ #region Fragment copy (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)
+ {
+ 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()
@@ -267,4 +454,5 @@ public partial class EditorViewModel : ViewModelBase
public ICompositor Compositor => _compositor;
public IPaletteService PaletteService => _paletteService;
public IDrawingService DrawingService => _drawingService;
+ public IFragmentService FragmentService => _fragmentService;
}
diff --git a/Minint/Views/MainWindow.axaml b/Minint/Views/MainWindow.axaml
index 0cffe84..2c7115d 100644
--- a/Minint/Views/MainWindow.axaml
+++ b/Minint/Views/MainWindow.axaml
@@ -37,12 +37,15 @@
BorderThickness="0,0,0,1" Padding="6,4">
@@ -72,19 +75,29 @@
-
+
+
+
+
+
+
+
-
+
@@ -109,14 +122,29 @@
BorderThickness="1,0,0,0" Padding="4">
+
+
+
+
+
+
+
-
-
+
+