From 400af7982e9ed20086006611528f40d32cbd4ebb 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 16:05:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Minint.Core/Models/MinintDocument.cs | 51 +++++++ Minint.Core/Services/Impl/PaletteService.cs | 31 +--- Minint/Controls/PixelCanvas.cs | 59 ++++++++ Minint/Minint.csproj | 1 + Minint/ViewModels/EditorViewModel.cs | 160 ++++++++++++++++++++ Minint/ViewModels/MainWindowViewModel.cs | 122 ++++++++++++++- Minint/Views/MainWindow.axaml | 73 ++++++++- Minint/Views/MainWindow.axaml.cs | 11 +- 8 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 Minint/Controls/PixelCanvas.cs create mode 100644 Minint/ViewModels/EditorViewModel.cs diff --git a/Minint.Core/Models/MinintDocument.cs b/Minint.Core/Models/MinintDocument.cs index 9ec0b67..5640a1b 100644 --- a/Minint.Core/Models/MinintDocument.cs +++ b/Minint.Core/Models/MinintDocument.cs @@ -21,6 +21,13 @@ public sealed class MinintDocument public List Layers { get; } + /// + /// Reverse lookup cache: RgbaColor → palette index. Built lazily, invalidated + /// on structural palette changes (compact, clear). Call + /// after bulk palette modifications. + /// + private Dictionary? _paletteCache; + public MinintDocument(string name) { Name = name; @@ -50,4 +57,48 @@ public sealed class MinintDocument <= 16_777_215 => 3, _ => 4 }; + + /// + /// O(1) lookup of a color in the palette. Returns the index, or -1 if not found. + /// Lazily builds an internal dictionary on first call. + /// + public int FindColorCached(RgbaColor color) + { + var cache = EnsurePaletteCache(); + return cache.TryGetValue(color, out int idx) ? idx : -1; + } + + /// + /// Returns the index of . If absent, appends it to the palette + /// and updates the cache. O(1) amortized. + /// + public int EnsureColorCached(RgbaColor color) + { + var cache = EnsurePaletteCache(); + if (cache.TryGetValue(color, out int idx)) + return idx; + + idx = Palette.Count; + Palette.Add(color); + cache[color] = idx; + return idx; + } + + /// + /// Drops the reverse lookup cache. Must be called after any operation that + /// reorders, removes, or bulk-replaces palette entries (e.g. compact, grayscale). + /// + public void InvalidatePaletteCache() => _paletteCache = null; + + private Dictionary EnsurePaletteCache() + { + if (_paletteCache is not null) + return _paletteCache; + + var cache = new Dictionary(Palette.Count); + for (int i = 0; i < Palette.Count; i++) + cache.TryAdd(Palette[i], i); // first occurrence wins (for dupes) + _paletteCache = cache; + return cache; + } } diff --git a/Minint.Core/Services/Impl/PaletteService.cs b/Minint.Core/Services/Impl/PaletteService.cs index b297910..ecf62f7 100644 --- a/Minint.Core/Services/Impl/PaletteService.cs +++ b/Minint.Core/Services/Impl/PaletteService.cs @@ -5,26 +5,10 @@ namespace Minint.Core.Services.Impl; public sealed class PaletteService : IPaletteService { public int FindColor(MinintDocument document, RgbaColor color) - { - var palette = document.Palette; - for (int i = 0; i < palette.Count; i++) - { - if (palette[i] == color) - return i; - } - return -1; - } + => document.FindColorCached(color); public int EnsureColor(MinintDocument document, RgbaColor color) - { - int idx = FindColor(document, color); - if (idx >= 0) - return idx; - - idx = document.Palette.Count; - document.Palette.Add(color); - return idx; - } + => document.EnsureColorCached(color); public void CompactPalette(MinintDocument document) { @@ -32,19 +16,16 @@ public sealed class PaletteService : IPaletteService if (palette.Count <= 1) return; - // 1. Collect indices actually used across all layers - var usedIndices = new HashSet { 0 }; // always keep transparent + var usedIndices = new HashSet { 0 }; foreach (var layer in document.Layers) { foreach (int idx in layer.Pixels) usedIndices.Add(idx); } - // 2. Build new palette and old→new mapping var oldToNew = new int[palette.Count]; var newPalette = new List(usedIndices.Count); - // Index 0 (transparent) stays at 0 newPalette.Add(palette[0]); oldToNew[0] = 0; @@ -55,23 +36,21 @@ public sealed class PaletteService : IPaletteService oldToNew[i] = newPalette.Count; newPalette.Add(palette[i]); } - // unused indices don't get a mapping — they'll never be looked up } - // 3. If nothing was removed, skip the remap if (newPalette.Count == palette.Count) return; - // 4. Replace palette palette.Clear(); palette.AddRange(newPalette); - // 5. Remap all pixel arrays foreach (var layer in document.Layers) { var px = layer.Pixels; for (int i = 0; i < px.Length; i++) px[i] = oldToNew[px[i]]; } + + document.InvalidatePaletteCache(); } } diff --git a/Minint/Controls/PixelCanvas.cs b/Minint/Controls/PixelCanvas.cs new file mode 100644 index 0000000..c1fc1ab --- /dev/null +++ b/Minint/Controls/PixelCanvas.cs @@ -0,0 +1,59 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; + +namespace Minint.Controls; + +/// +/// Custom control that renders a WriteableBitmap with nearest-neighbor interpolation. +/// Pan/zoom will be added in Stage 6. +/// +public class PixelCanvas : Control +{ + public static readonly StyledProperty SourceBitmapProperty = + AvaloniaProperty.Register(nameof(SourceBitmap)); + + public WriteableBitmap? SourceBitmap + { + get => GetValue(SourceBitmapProperty); + set => SetValue(SourceBitmapProperty, value); + } + + static PixelCanvas() + { + AffectsRender(SourceBitmapProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bmp = SourceBitmap; + if (bmp is null) + return; + + var srcSize = bmp.PixelSize; + var bounds = Bounds; + + // Fit image into control bounds preserving aspect ratio, centered + double scaleX = bounds.Width / srcSize.Width; + double scaleY = bounds.Height / srcSize.Height; + double scale = Math.Min(scaleX, scaleY); + if (scale < 1) scale = Math.Max(1, Math.Floor(scale)); + else scale = Math.Max(1, Math.Floor(scale)); + + double dstW = srcSize.Width * scale; + double dstH = srcSize.Height * scale; + double offsetX = (bounds.Width - dstW) / 2; + double offsetY = (bounds.Height - dstH) / 2; + + var destRect = new Rect(offsetX, offsetY, dstW, dstH); + var srcRect = new Rect(0, 0, srcSize.Width, srcSize.Height); + + // Nearest-neighbor for pixel-perfect rendering + RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None); + context.DrawImage(bmp, srcRect, destRect); + } +} diff --git a/Minint/Minint.csproj b/Minint/Minint.csproj index 137afa5..9a8fa38 100644 --- a/Minint/Minint.csproj +++ b/Minint/Minint.csproj @@ -3,6 +3,7 @@ WinExe net10.0 enable + true true app.manifest true diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs new file mode 100644 index 0000000..18e9146 --- /dev/null +++ b/Minint/ViewModels/EditorViewModel.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using Minint.Core.Models; +using Minint.Core.Services; +using Minint.Core.Services.Impl; + +namespace Minint.ViewModels; + +public partial class EditorViewModel : ViewModelBase +{ + private readonly ICompositor _compositor = new Compositor(); + private readonly IPaletteService _paletteService = new PaletteService(); + + [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; + + /// + /// Path of the currently open file, or null for unsaved new containers. + /// + [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 Documents { get; } = []; + public ObservableCollection Layers { get; } = []; + + 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; + + Documents.Clear(); + foreach (var doc in container.Documents) + Documents.Add(doc); + + SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null); + } + + /// + /// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding). + /// + partial void OnActiveDocumentChanged(MinintDocument? value) + { + if (_suppressDocumentSync) return; + SyncLayersAndCanvas(value); + } + + public void SelectDocument(MinintDocument? doc) + { + _suppressDocumentSync = true; + ActiveDocument = doc; + _suppressDocumentSync = false; + + SyncLayersAndCanvas(doc); + } + + private void SyncLayersAndCanvas(MinintDocument? doc) + { + 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; + } + + RefreshCanvas(); + } + + 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((void*)fb.Address, w * h); + for (int i = 0; i < argb.Length; i++) + { + // argb[i] is 0xAARRGGBB, need premultiplied BGRA for the bitmap + 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; // ARGB layout == BGRA in LE memory, alpha=255 → no premul needed + } + 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; + } + + public ICompositor Compositor => _compositor; + public IPaletteService PaletteService => _paletteService; +} diff --git a/Minint/ViewModels/MainWindowViewModel.cs b/Minint/ViewModels/MainWindowViewModel.cs index a1b406a..887c256 100644 --- a/Minint/ViewModels/MainWindowViewModel.cs +++ b/Minint/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,124 @@ -namespace Minint.ViewModels; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Minint.Infrastructure.Serialization; + +namespace Minint.ViewModels; public partial class MainWindowViewModel : ViewModelBase { - public string Greeting { get; } = "Welcome to Avalonia!"; + private readonly MinintSerializer _serializer = new(); + + private static readonly FilePickerFileType MinintFileType = new("Minint Files") + { + Patterns = ["*.minint"], + }; + + [ObservableProperty] + private EditorViewModel _editor = new(); + + [ObservableProperty] + private string _statusText = "Ready"; + + /// + /// Set by the view so that file dialogs can use the correct parent window. + /// + public TopLevel? Owner { get; set; } + + [RelayCommand] + private void NewFile() + { + Editor.NewContainer(64, 64); + StatusText = "New 64×64 container created."; + } + + [RelayCommand] + private async Task OpenFileAsync() + { + if (Owner?.StorageProvider is not { } sp) return; + + var files = await sp.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open .minint file", + FileTypeFilter = [MinintFileType], + AllowMultiple = false, + }); + + if (files.Count == 0) return; + + var file = files[0]; + try + { + await using var stream = await file.OpenReadAsync(); + var container = _serializer.Read(stream); + var path = file.TryGetLocalPath(); + Editor.LoadContainer(container, path); + StatusText = $"Opened {file.Name}"; + } + catch (Exception ex) + { + StatusText = $"Error opening file: {ex.Message}"; + } + } + + [RelayCommand] + private async Task SaveFileAsync() + { + if (Editor.Container is null) return; + + if (Editor.FilePath is not null) + { + await SaveToPathAsync(Editor.FilePath); + } + else + { + await SaveFileAsAsync(); + } + } + + [RelayCommand] + private async Task SaveFileAsAsync() + { + if (Owner?.StorageProvider is not { } sp || Editor.Container is null) return; + + var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save .minint file", + DefaultExtension = "minint", + FileTypeChoices = [MinintFileType], + SuggestedFileName = Editor.FilePath is not null + ? Path.GetFileName(Editor.FilePath) : "untitled.minint", + }); + + if (file is null) return; + + var path = file.TryGetLocalPath(); + if (path is null) + { + StatusText = "Error: could not resolve file path."; + return; + } + + await SaveToPathAsync(path); + } + + private async Task SaveToPathAsync(string path) + { + try + { + await using var fs = File.Create(path); + _serializer.Write(fs, Editor.Container!); + Editor.FilePath = path; + StatusText = $"Saved {Path.GetFileName(path)}"; + } + catch (Exception ex) + { + StatusText = $"Error saving file: {ex.Message}"; + } + } } diff --git a/Minint/Views/MainWindow.axaml b/Minint/Views/MainWindow.axaml index 835c3cb..261aed7 100644 --- a/Minint/Views/MainWindow.axaml +++ b/Minint/Views/MainWindow.axaml @@ -1,20 +1,83 @@ + Title="{Binding Editor.Title}" + Width="1024" Height="700"> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Minint/Views/MainWindow.axaml.cs b/Minint/Views/MainWindow.axaml.cs index 98ae583..2dcfc90 100644 --- a/Minint/Views/MainWindow.axaml.cs +++ b/Minint/Views/MainWindow.axaml.cs @@ -1,4 +1,6 @@ +using System; using Avalonia.Controls; +using Minint.ViewModels; namespace Minint.Views; @@ -8,4 +10,11 @@ public partial class MainWindow : Window { InitializeComponent(); } -} \ No newline at end of file + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + if (DataContext is MainWindowViewModel vm) + vm.Owner = this; + } +}