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