Files
Minint/Report/lab2/zivro-lab2-report-with-code.md
2026-04-07 21:00:49 +03:00

4883 lines
152 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Лабораторная работа 2
## Способы и средства хранения и обработки графических данных
### Вариант ИВ1: разработка растрового редактора
## 1. Цель и практический результат
Цель работы - разработать растровый редактор, выполняющий создание, загрузку, редактирование и сохранение графического контейнера с пиксельными данными, а также реализовать базовые инструменты рисования и редактирования фрагментов.
Практический результат:
- разработано настольное приложение `Minint` на `C#` + `Avalonia`;
- реализован собственный бинарный формат контейнера `.minint` с чтением/записью;
- реализованы инструменты `Brush`, `Eraser`, `Fill`, `Select`, `Copy/Cut/Paste`;
- подготовлена документация с UML-диаграммами и приложением исходного кода.
![Скриншот ПО](Screenshot.png)
## 2. Соответствие варианту ИВ1 и замечание по структуре контейнера
По методическим указаниям ИВ1 требуется использовать структуру пикселя и контейнера из одного из вариантов `КВ1КВ4`.
В текущем проекте фактически реализован палитровый контейнер с `RGBA`-палитрой и индексами пикселей (`MinintContainer`/`MinintDocument`/`MinintLayer`), что не совпадает буквально с описаниями `КВ1КВ4`, но полностью закрывает функциональные требования ИВ1 (создание, редактирование, загрузка, сохранение, инструменты рисования, работа с фрагментами).
Это ограничение фиксируется в отчёте явно, чтобы не было расхождения между кодом и документацией.
## 3. Выполнение основных требований ИВ1
### 3.1 Создание, загрузка и сохранение контейнера
- создание нового контейнера выполняется через `EditorViewModel.NewContainer(...)`;
- загрузка/сохранение выполняется через `MinintSerializer` (собственная реализация чтения/записи);
- контейнер хранит общие размеры, набор документов (кадров), палитры и слои.
Реализация: `Minint/ViewModels/EditorViewModel.cs`, `Minint.Infrastructure/Serialization/MinintSerializer.cs`, `Minint.Core/Models/*`.
### 3.2 Редактирование единичных пикселей
- инструмент `Brush` изменяет значения пикселей маской радиуса;
- инструмент `Eraser` записывает индекс прозрачного цвета (`0`);
- выбор цвета выполняется через текущий `SelectedColor` и палитру документа.
Реализация: `Minint.Core/Services/Impl/DrawingService.cs`, `Minint/ViewModels/EditorViewModel.cs`.
### 3.3 Непрерывная отрисовка "кистью"
- при перемещении мыши по зажатой кнопке вызывается последовательная обработка точек;
- маска кисти вычисляется как круг по радиусу;
- поддерживается визуальный preview маски инструмента.
Реализация: `Minint/Controls/PixelCanvas.cs`, `Minint/Core/Services/Impl/DrawingService.cs`, `Minint/ViewModels/EditorViewModel.cs`.
### 3.4 Закраска области ("заливка")
- реализован алгоритм flood fill (4-связность);
- заливка ограничена областью одинакового исходного индекса;
- алгоритм работает в границах изображения.
Реализация: `Minint.Core/Services/Impl/FloodFillService.cs`.
### 3.5 Выделение, копирование/вырезание и вставка фрагмента
- реализована рамка выделения (`SelectionRect`);
- данные буфера обмена хранятся в palette-independent виде (`ClipboardFragment`, `RGBA`);
- вставка поддерживает предпросмотр и подтверждение позиции;
- прозрачные пиксели фрагмента при вставке пропускаются.
Реализация: `Minint/ViewModels/EditorViewModel.cs`, `Minint/Controls/PixelCanvas.cs`, `Minint.Core/Services/Impl/FragmentService.cs`.
## 4. Структура контейнера и пикселя
### 4.1 Структура контейнера `.minint`
Контейнер состоит из:
1. Заголовка файла:
- сигнатура `MININT`;
- версия формата;
- ширина и высота;
- количество документов;
- резерв.
2. Набора документов:
- имя документа;
- задержка кадра;
- палитра `RGBA`;
- набор слоёв.
3. Набора слоёв:
- имя слоя;
- признак видимости;
- непрозрачность;
- массив индексов пикселей.
### 4.2 Структура пикселя
Логически пиксель хранится как индекс в палитре документа (`int` в ОЗУ, переменная ширина 1..4 байта в файле), а итоговый цвет формируется по таблице `RgbaColor`.
## 5. Основные алгоритмы
1. Запись контейнера в бинарный поток (`WriteHeader`, `WriteDocument`, `WriteLayer`).
2. Чтение и валидация контейнера (`ReadHeader`, `ReadDocument`, `ReadLayer`).
3. Круглая кисть по маске радиуса.
4. Очистка пикселей ластиком.
5. Flood fill по очереди.
6. Копирование/вставка фрагмента с отсечением по границам.
7. Композиция слоёв при обновлении холста.
## 6. UML-диаграммы (PlantUML)
### 6.1 Основной рабочий цикл редактора
`Report/lab2/uml/lr2-editor-workflow.puml`
![Основной рабочий цикл](uml/lr2-editor-workflow.png)
### 6.2 Формат контейнера и сериализация
`Report/lab2/uml/lr2-container-serialization.puml`
![Контейнер и сериализация](uml/lr2-container-serialization.png)
### 6.3 Инструменты рисования и заливки
`Report/lab2/uml/lr2-tools-and-fill.puml`
![Инструменты и заливка](uml/lr2-tools-and-fill.png)
### 6.4 Выделение и буфер обмена фрагментов
`Report/lab2/uml/lr2-selection-copy-paste.puml`
![Выделение и copy/paste](uml/lr2-selection-copy-paste.png)
## 7. Проверка работоспособности
Для проверки корректности реализации используются модульные тесты проекта `Minint.Tests`:
- `DrawingTests`;
- `FloodFillTests`;
- `FragmentServiceTests`;
- `SerializerTests`;
- `CompositorTests`;
- `ExportTests`.
## 8. Вывод
В рамках ЛР2 (вариант ИВ1) реализовано рабочее приложение-растровый редактор с собственным контейнером данных и базовым набором инструментов редактирования изображения. Практические требования ИВ1 закрыты на уровне пользовательского сценария и программной реализации.
Отдельно зафиксировано, что выбранная структура контейнера является палитровой и не повторяет буквально формулировки `КВ1КВ4`; при этом это не противоречит задаче разработки редактора и демонстрирует полноценную обработку графических данных.
---
## Приложение A. Исходные тексты
Сформировано автоматически скриптом `Report/append_sources_to_report.py` (файлов: 48).
### A.1. `Minint.Core/Models/MinintContainer.cs`
```csharp
namespace Minint.Core.Models;
/// <summary>
/// Top-level container: holds dimensions shared by all documents/layers,
/// and a list of documents (frames).
/// </summary>
public sealed class MinintContainer
{
public int Width { get; set; }
public int Height { get; set; }
public List<MinintDocument> Documents { get; }
public int PixelCount => Width * Height;
public MinintContainer(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
Width = width;
Height = height;
Documents = [];
}
/// <summary>
/// Creates a new document with a single transparent layer and adds it to the container.
/// </summary>
public MinintDocument AddNewDocument(string name)
{
var doc = new MinintDocument(name);
doc.Layers.Add(new MinintLayer("Layer 1", PixelCount));
Documents.Add(doc);
return doc;
}
}
```
### A.2. `Minint.Core/Models/MinintDocument.cs`
```csharp
namespace Minint.Core.Models;
/// <summary>
/// A single document (frame) within a container.
/// Has its own palette shared by all layers, plus a list of layers.
/// </summary>
public sealed class MinintDocument
{
public string Name { get; set; }
/// <summary>
/// Delay before showing the next frame during animation playback (ms).
/// </summary>
public uint FrameDelayMs { get; set; }
/// <summary>
/// Document palette. Index 0 is always <see cref="RgbaColor.Transparent"/>.
/// All layers reference colors by index into this list.
/// </summary>
public List<RgbaColor> Palette { get; }
public List<MinintLayer> Layers { get; }
/// <summary>
/// Reverse lookup cache: RgbaColor → palette index. Built lazily, invalidated
/// on structural palette changes (compact, clear). Call <see cref="InvalidatePaletteCache"/>
/// after bulk palette modifications.
/// </summary>
private Dictionary<RgbaColor, int>? _paletteCache;
public MinintDocument(string name)
{
Name = name;
FrameDelayMs = 100;
Palette = [RgbaColor.Transparent];
Layers = [];
}
/// <summary>
/// Constructor for deserialization — accepts pre-built palette and layers.
/// </summary>
public MinintDocument(string name, uint frameDelayMs, List<RgbaColor> palette, List<MinintLayer> layers)
{
Name = name;
FrameDelayMs = frameDelayMs;
Palette = palette;
Layers = layers;
}
/// <summary>
/// Returns the number of bytes needed to store a single palette index on disk.
/// </summary>
public int IndexByteWidth => Palette.Count switch
{
<= 255 => 1,
<= 65_535 => 2,
<= 16_777_215 => 3,
_ => 4
};
/// <summary>
/// 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.
/// </summary>
public int FindColorCached(RgbaColor color)
{
var cache = EnsurePaletteCache();
return cache.GetValueOrDefault(color, -1);
}
/// <summary>
/// Returns the index of <paramref name="color"/>. If absent, appends it to the palette
/// and updates the cache. O(1) amortized.
/// </summary>
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;
}
/// <summary>
/// Drops the reverse lookup cache. Must be called after any operation that
/// reorders, removes, or bulk-replaces palette entries (e.g. compact, grayscale).
/// </summary>
public void InvalidatePaletteCache() => _paletteCache = null;
private Dictionary<RgbaColor, int> EnsurePaletteCache()
{
if (_paletteCache is not null)
return _paletteCache;
var cache = new Dictionary<RgbaColor, int>(Palette.Count);
for (int i = 0; i < Palette.Count; i++)
cache.TryAdd(Palette[i], i); // first occurrence wins (for dupes)
_paletteCache = cache;
return cache;
}
}
```
### A.3. `Minint.Core/Models/MinintLayer.cs`
```csharp
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Minint.Core.Models;
/// <summary>
/// A single raster layer. Pixels are indices into the parent document's palette.
/// Array layout is row-major: Pixels[y * width + x].
/// </summary>
public sealed class MinintLayer : INotifyPropertyChanged
{
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(); } }
}
/// <summary>
/// Per-layer opacity (0 = fully transparent, 255 = fully opaque).
/// Used during compositing: effective alpha = paletteColor.A * Opacity / 255.
/// </summary>
public byte Opacity
{
get => _opacity;
set { if (_opacity != value) { _opacity = value; Notify(); } }
}
/// <summary>
/// Palette indices, length must equal container Width * Height.
/// Index 0 = transparent by convention.
/// </summary>
public int[] Pixels { get; }
public MinintLayer(string name, int pixelCount)
{
_name = name;
_isVisible = true;
_opacity = 255;
Pixels = new int[pixelCount];
}
/// <summary>
/// Constructor for deserialization — accepts a pre-filled pixel buffer.
/// </summary>
public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels)
{
_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));
}
```
### A.4. `Minint.Core/Models/RgbaColor.cs`
```csharp
using System.Runtime.InteropServices;
namespace Minint.Core.Models;
/// <summary>
/// 4-byte RGBA color value. Equality is component-wise.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public readonly record struct RgbaColor(byte R, byte G, byte B, byte A)
{
public static readonly RgbaColor Transparent = new(0, 0, 0, 0);
public static readonly RgbaColor Black = new(0, 0, 0, 255);
public static readonly RgbaColor White = new(255, 255, 255, 255);
/// <summary>
/// Packs color into a single uint as 0xAABBGGRR (little-endian RGBA).
/// Suitable for writing directly into BGRA bitmap buffers after byte-swap,
/// or for use as a dictionary key.
/// </summary>
public uint ToPackedRgba() =>
(uint)(R | (G << 8) | (B << 16) | (A << 24));
public static RgbaColor FromPackedRgba(uint packed) =>
new(
(byte)(packed & 0xFF),
(byte)((packed >> 8) & 0xFF),
(byte)((packed >> 16) & 0xFF),
(byte)((packed >> 24) & 0xFF));
/// <summary>
/// Packs as 0xAARRGGBB — used for Avalonia/SkiaSharp pixel buffers.
/// </summary>
public uint ToPackedArgb() =>
(uint)(B | (G << 8) | (R << 16) | (A << 24));
public override string ToString() => $"#{R:X2}{G:X2}{B:X2}{A:X2}";
}
```
### A.5. `Minint.Core/Services/IBmpExporter.cs`
```csharp
namespace Minint.Core.Services;
public interface IBmpExporter
{
/// <summary>
/// Exports a composited ARGB pixel buffer as a 32-bit BMP file.
/// </summary>
/// <param name="stream">Output stream.</param>
/// <param name="pixels">Pixel data packed as 0xAARRGGBB, row-major.</param>
/// <param name="width">Image width.</param>
/// <param name="height">Image height.</param>
void Export(Stream stream, uint[] pixels, int width, int height);
}
```
### A.6. `Minint.Core/Services/ICompositor.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface ICompositor
{
/// <summary>
/// Composites all visible layers of <paramref name="document"/> into a flat RGBA buffer.
/// Result is packed as ARGB (0xAARRGGBB) per pixel, row-major, length = width * height.
/// Layers are blended bottom-to-top with alpha compositing.
/// </summary>
uint[] Composite(MinintDocument document, int width, int height);
}
```
### A.7. `Minint.Core/Services/IDrawingService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IDrawingService
{
/// <summary>
/// Applies a circular brush stroke at (<paramref name="cx"/>, <paramref name="cy"/>)
/// with the given <paramref name="radius"/>. Sets affected pixels to <paramref name="colorIndex"/>.
/// </summary>
void ApplyBrush(MinintLayer layer, int cx, int cy, int radius, int colorIndex, int width, int height);
/// <summary>
/// Applies a circular eraser at (<paramref name="cx"/>, <paramref name="cy"/>)
/// with the given <paramref name="radius"/>. Sets affected pixels to index 0 (transparent).
/// </summary>
void ApplyEraser(MinintLayer layer, int cx, int cy, int radius, int width, int height);
/// <summary>
/// Returns the set of pixel coordinates affected by a circular brush/eraser
/// centered at (<paramref name="cx"/>, <paramref name="cy"/>) with given <paramref name="radius"/>.
/// Used for tool preview overlay.
/// </summary>
List<(int X, int Y)> GetBrushMask(int cx, int cy, int radius, int width, int height);
}
```
### A.8. `Minint.Core/Services/IFloodFillService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IFloodFillService
{
/// <summary>
/// Flood-fills a contiguous region of identical color starting at (<paramref name="x"/>, <paramref name="y"/>)
/// with <paramref name="newColorIndex"/>. Uses 4-connectivity (up/down/left/right).
/// </summary>
void Fill(MinintLayer layer, int x, int y, int newColorIndex, int width, int height);
}
```
### A.9. `Minint.Core/Services/IFragmentService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IFragmentService
{
/// <summary>
/// Copies a rectangular region from one document/layer to another.
/// Palette colors are merged: missing colors are added to the destination palette.
/// </summary>
/// <param name="srcDoc">Source document.</param>
/// <param name="srcLayerIndex">Index of the source layer.</param>
/// <param name="srcX">Source rectangle X origin.</param>
/// <param name="srcY">Source rectangle Y origin.</param>
/// <param name="regionWidth">Width of the region to copy.</param>
/// <param name="regionHeight">Height of the region to copy.</param>
/// <param name="dstDoc">Destination document.</param>
/// <param name="dstLayerIndex">Index of the destination layer.</param>
/// <param name="dstX">Destination X origin.</param>
/// <param name="dstY">Destination Y origin.</param>
/// <param name="containerWidth">Container width (shared by both docs).</param>
/// <param name="containerHeight">Container height (shared by both docs).</param>
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);
}
```
### A.10. `Minint.Core/Services/IGifExporter.cs`
```csharp
namespace Minint.Core.Services;
public interface IGifExporter
{
/// <summary>
/// Exports multiple frames as an animated GIF.
/// </summary>
/// <param name="stream">Output stream.</param>
/// <param name="frames">Sequence of (ARGB pixels, delay in ms) per frame.</param>
/// <param name="width">Frame width.</param>
/// <param name="height">Frame height.</param>
void Export(Stream stream, IReadOnlyList<(uint[] Pixels, uint DelayMs)> frames, int width, int height);
}
```
### A.11. `Minint.Core/Services/IImageEffectService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IImageEffectService
{
/// <summary>
/// Adjusts contrast of the document by modifying palette colors.
/// <paramref name="factor"/> > 1 increases contrast, &lt; 1 decreases.
/// Index 0 (transparent) is not modified.
/// </summary>
void AdjustContrast(MinintDocument document, float factor);
/// <summary>
/// Converts the document to grayscale by modifying palette colors.
/// Uses ITU-R BT.601 luminance: gray = 0.299R + 0.587G + 0.114B.
/// Index 0 (transparent) is not modified.
/// </summary>
void ToGrayscale(MinintDocument document);
}
```
### A.12. `Minint.Core/Services/IImageEffectsService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IImageEffectsService
{
/// <summary>
/// Adjusts contrast of the document by transforming its palette colors.
/// <paramref name="factor"/> of 0 = all gray, 1 = no change, >1 = increased contrast.
/// </summary>
void ApplyContrast(MinintDocument doc, double factor);
/// <summary>
/// Converts the document to grayscale by transforming its palette colors
/// using the luminance formula: 0.299R + 0.587G + 0.114B.
/// </summary>
void ApplyGrayscale(MinintDocument doc);
}
```
### A.13. `Minint.Core/Services/IMinintSerializer.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IMinintSerializer
{
/// <summary>
/// Serializes the container to a binary .minint stream.
/// </summary>
void Write(Stream stream, MinintContainer container);
/// <summary>
/// Deserializes a .minint stream into a container.
/// Throws <see cref="InvalidDataException"/> on format/validation errors.
/// </summary>
MinintContainer Read(Stream stream);
}
```
### A.14. `Minint.Core/Services/IPaletteService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public interface IPaletteService
{
/// <summary>
/// Returns the index of <paramref name="color"/> in the document palette.
/// If the color is not present, appends it and returns the new index.
/// </summary>
int EnsureColor(MinintDocument document, RgbaColor color);
/// <summary>
/// Finds index of an exact color match, or returns -1 if not found.
/// </summary>
int FindColor(MinintDocument document, RgbaColor color);
/// <summary>
/// Removes unused colors from the palette and remaps all layer pixel indices.
/// Index 0 (transparent) is always preserved.
/// </summary>
void CompactPalette(MinintDocument document);
}
```
### A.15. `Minint.Core/Services/IPatternGenerator.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services;
public enum PatternType
{
Checkerboard,
HorizontalGradient,
VerticalGradient,
HorizontalStripes,
VerticalStripes,
ConcentricCircles,
Tile
}
public interface IPatternGenerator
{
/// <summary>
/// Generates a new document with a single layer filled with the specified pattern.
/// </summary>
/// <param name="type">Pattern type.</param>
/// <param name="width">Image width in pixels.</param>
/// <param name="height">Image height in pixels.</param>
/// <param name="colors">Colors to use (interpretation depends on pattern type).</param>
/// <param name="param1">Primary parameter: cell/stripe size, ring width, etc.</param>
/// <param name="param2">Secondary parameter (optional, pattern-dependent).</param>
MinintDocument Generate(PatternType type, int width, int height, RgbaColor[] colors, int param1, int param2 = 0);
}
```
### A.16. `Minint.Core/Services/Impl/Compositor.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class Compositor : ICompositor
{
/// <inheritdoc />
public uint[] Composite(MinintDocument document, int width, int height)
{
int pixelCount = width * height;
var result = new uint[pixelCount]; // starts as 0x00000000 (transparent black)
var palette = document.Palette;
foreach (var layer in document.Layers)
{
if (!layer.IsVisible)
continue;
byte layerOpacity = layer.Opacity;
if (layerOpacity == 0)
continue;
var pixels = layer.Pixels;
for (int i = 0; i < pixelCount; i++)
{
int idx = pixels[i];
if (idx == 0)
continue; // transparent — skip
var src = palette[idx];
// Effective source alpha = palette alpha * layer opacity / 255
int srcA = src.A * layerOpacity / 255;
if (srcA == 0)
continue;
if (srcA == 255)
{
// Fully opaque — fast path, no blending needed
result[i] = PackArgb(src.R, src.G, src.B, 255);
continue;
}
// Standard "over" alpha compositing
uint dst = result[i];
int dstA = (int)(dst >> 24);
int dstR = (int)((dst >> 16) & 0xFF);
int dstG = (int)((dst >> 8) & 0xFF);
int dstB = (int)(dst & 0xFF);
int outA = srcA + dstA * (255 - srcA) / 255;
if (outA == 0)
continue;
int outR = (src.R * srcA + dstR * dstA * (255 - srcA) / 255) / outA;
int outG = (src.G * srcA + dstG * dstA * (255 - srcA) / 255) / outA;
int outB = (src.B * srcA + dstB * dstA * (255 - srcA) / 255) / outA;
result[i] = PackArgb(
(byte)Math.Min(outR, 255),
(byte)Math.Min(outG, 255),
(byte)Math.Min(outB, 255),
(byte)Math.Min(outA, 255));
}
}
return result;
}
private static uint PackArgb(byte r, byte g, byte b, byte a) =>
(uint)(b | (g << 8) | (r << 16) | (a << 24));
}
```
### A.17. `Minint.Core/Services/Impl/DrawingService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class DrawingService : IDrawingService
{
public void ApplyBrush(MinintLayer layer, int cx, int cy, int radius, int colorIndex, int width, int height)
{
foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
layer.Pixels[y * width + x] = colorIndex;
}
public void ApplyEraser(MinintLayer layer, int cx, int cy, int radius, int width, int height)
{
foreach (var (x, y) in GetBrushMask(cx, cy, radius, width, height))
layer.Pixels[y * width + x] = 0;
}
public List<(int X, int Y)> GetBrushMask(int cx, int cy, int radius, int width, int height)
{
var mask = new List<(int, int)>();
int r = Math.Max(radius, 0);
int r2 = r * r;
int xMin = Math.Max(0, cx - r);
int xMax = Math.Min(width - 1, cx + r);
int yMin = Math.Max(0, cy - r);
int yMax = Math.Min(height - 1, cy + r);
for (int py = yMin; py <= yMax; py++)
{
int dy = py - cy;
for (int px = xMin; px <= xMax; px++)
{
int dx = px - cx;
if (dx * dx + dy * dy <= r2)
mask.Add((px, py));
}
}
return mask;
}
}
```
### A.18. `Minint.Core/Services/Impl/FloodFillService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class FloodFillService : IFloodFillService
{
public void Fill(MinintLayer layer, int x, int y, int newColorIndex, int width, int height)
{
if (x < 0 || x >= width || y < 0 || y >= height)
return;
var pixels = layer.Pixels;
int targetIndex = pixels[y * width + x];
if (targetIndex == newColorIndex)
return;
var queue = new Queue<(int X, int Y)>();
var visited = new bool[width * height];
queue.Enqueue((x, y));
visited[y * width + x] = true;
while (queue.Count > 0)
{
var (cx, cy) = queue.Dequeue();
pixels[cy * width + cx] = newColorIndex;
Span<(int, int)> neighbors =
[
(cx - 1, cy), (cx + 1, cy),
(cx, cy - 1), (cx, cy + 1)
];
foreach (var (nx, ny) in neighbors)
{
if (nx < 0 || nx >= width || ny < 0 || ny >= height)
continue;
int ni = ny * width + nx;
if (visited[ni] || pixels[ni] != targetIndex)
continue;
visited[ni] = true;
queue.Enqueue((nx, ny));
}
}
}
}
```
### A.19. `Minint.Core/Services/Impl/FragmentService.cs`
```csharp
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;
}
}
}
}
```
### A.20. `Minint.Core/Services/Impl/ImageEffectsService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class ImageEffectsService : IImageEffectsService
{
public void ApplyContrast(MinintDocument doc, double factor)
{
for (int i = 1; i < doc.Palette.Count; i++)
{
var c = doc.Palette[i];
doc.Palette[i] = new RgbaColor(
ContrastByte(c.R, factor),
ContrastByte(c.G, factor),
ContrastByte(c.B, factor),
c.A);
}
doc.InvalidatePaletteCache();
}
public void ApplyGrayscale(MinintDocument doc)
{
for (int i = 1; i < doc.Palette.Count; i++)
{
var c = doc.Palette[i];
byte gray = (byte)Math.Clamp((int)(0.299 * c.R + 0.587 * c.G + 0.114 * c.B + 0.5), 0, 255);
doc.Palette[i] = new RgbaColor(gray, gray, gray, c.A);
}
doc.InvalidatePaletteCache();
}
private static byte ContrastByte(byte value, double factor)
{
double v = ((value / 255.0) - 0.5) * factor + 0.5;
return (byte)Math.Clamp((int)(v * 255 + 0.5), 0, 255);
}
}
```
### A.21. `Minint.Core/Services/Impl/PaletteService.cs`
```csharp
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class PaletteService : IPaletteService
{
public int FindColor(MinintDocument document, RgbaColor color)
=> document.FindColorCached(color);
public int EnsureColor(MinintDocument document, RgbaColor color)
=> document.EnsureColorCached(color);
public void CompactPalette(MinintDocument document)
{
var palette = document.Palette;
if (palette.Count <= 1)
return;
var usedIndices = new HashSet<int> { 0 };
foreach (var layer in document.Layers)
{
foreach (int idx in layer.Pixels)
usedIndices.Add(idx);
}
var oldToNew = new int[palette.Count];
var newPalette = new List<RgbaColor>(usedIndices.Count);
newPalette.Add(palette[0]);
oldToNew[0] = 0;
for (int i = 1; i < palette.Count; i++)
{
if (usedIndices.Contains(i))
{
oldToNew[i] = newPalette.Count;
newPalette.Add(palette[i]);
}
}
if (newPalette.Count == palette.Count)
return;
palette.Clear();
palette.AddRange(newPalette);
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();
}
}
```
### A.22. `Minint.Core/Services/Impl/PatternGenerator.cs`
```csharp
using System;
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class PatternGenerator : IPatternGenerator
{
public MinintDocument Generate(PatternType type, int width, int height, RgbaColor[] colors, int param1, int param2 = 0)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
if (colors.Length < 2)
throw new ArgumentException("At least two colors are required.", nameof(colors));
var doc = new MinintDocument($"Pattern ({type})");
var layer = new MinintLayer("Pattern", width * height);
doc.Layers.Add(layer);
int[] colorIndices = new int[colors.Length];
for (int i = 0; i < colors.Length; i++)
colorIndices[i] = doc.EnsureColorCached(colors[i]);
int cellSize = Math.Max(param1, 1);
switch (type)
{
case PatternType.Checkerboard:
FillCheckerboard(layer.Pixels, width, height, colorIndices, cellSize);
break;
case PatternType.HorizontalGradient:
FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: true);
break;
case PatternType.VerticalGradient:
FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: false);
break;
case PatternType.HorizontalStripes:
FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: true);
break;
case PatternType.VerticalStripes:
FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: false);
break;
case PatternType.ConcentricCircles:
FillCircles(layer.Pixels, width, height, colorIndices, cellSize);
break;
case PatternType.Tile:
FillTile(layer.Pixels, width, height, colorIndices, cellSize, Math.Max(param2, 1));
break;
}
return doc;
}
private static void FillCheckerboard(int[] pixels, int w, int h, int[] ci, int cell)
{
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
pixels[y * w + x] = ci[((x / cell) + (y / cell)) % 2 == 0 ? 0 : 1];
}
private static void FillGradient(int[] pixels, int w, int h, RgbaColor c0, RgbaColor c1,
MinintDocument doc, bool horizontal)
{
int steps = horizontal ? w : h;
for (int s = 0; s < steps; s++)
{
double t = steps > 1 ? (double)s / (steps - 1) : 0;
var c = new RgbaColor(
Lerp(c0.R, c1.R, t), Lerp(c0.G, c1.G, t),
Lerp(c0.B, c1.B, t), Lerp(c0.A, c1.A, t));
int idx = doc.EnsureColorCached(c);
if (horizontal)
for (int y = 0; y < h; y++) pixels[y * w + s] = idx;
else
for (int x = 0; x < w; x++) pixels[s * w + x] = idx;
}
}
private static void FillStripes(int[] pixels, int w, int h, int[] ci, int stripe, bool horizontal)
{
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int coord = horizontal ? y : x;
pixels[y * w + x] = ci[(coord / stripe) % ci.Length];
}
}
private static void FillCircles(int[] pixels, int w, int h, int[] ci, int ringWidth)
{
double cx = (w - 1) / 2.0, cy = (h - 1) / 2.0;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
double dist = Math.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
int ring = (int)(dist / ringWidth);
pixels[y * w + x] = ci[ring % ci.Length];
}
}
private static void FillTile(int[] pixels, int w, int h, int[] ci, int tileW, int tileH)
{
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int tx = (x / tileW) % ci.Length;
int ty = (y / tileH) % ci.Length;
pixels[y * w + x] = ci[(tx + ty) % ci.Length];
}
}
private static byte Lerp(byte a, byte b, double t)
=> (byte)Math.Clamp((int)(a + (b - a) * t + 0.5), 0, 255);
}
```
### A.23. `Minint.Infrastructure/Export/BmpExporter.cs`
```csharp
using Minint.Core.Services;
namespace Minint.Infrastructure.Export;
/// <summary>
/// Writes a 32-bit BGRA BMP (BITMAPV4HEADER) from an ARGB pixel buffer.
/// BMP rows are bottom-up, so we flip vertically during write.
/// </summary>
public sealed class BmpExporter : IBmpExporter
{
private const int BmpFileHeaderSize = 14;
private const int BitmapV4HeaderSize = 108;
private const int HeadersTotal = BmpFileHeaderSize + BitmapV4HeaderSize;
public void Export(Stream stream, uint[] pixels, int width, int height)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(pixels);
if (pixels.Length != width * height)
throw new ArgumentException("Pixel buffer size does not match dimensions.");
int rowBytes = width * 4;
int imageSize = rowBytes * height;
int fileSize = HeadersTotal + imageSize;
using var w = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
// BITMAPFILEHEADER (14 bytes)
w.Write((byte)'B');
w.Write((byte)'M');
w.Write(fileSize);
w.Write((ushort)0); // reserved1
w.Write((ushort)0); // reserved2
w.Write(HeadersTotal); // pixel data offset
// BITMAPV4HEADER (108 bytes)
w.Write(BitmapV4HeaderSize);
w.Write(width);
w.Write(height); // positive = bottom-up
w.Write((ushort)1); // planes
w.Write((ushort)32); // bpp
w.Write(3); // biCompression = BI_BITFIELDS
w.Write(imageSize);
w.Write(2835); // X pixels per meter (~72 DPI)
w.Write(2835); // Y pixels per meter
w.Write(0); // colors used
w.Write(0); // important colors
// Channel masks (BGRA order in file)
w.Write(0x00FF0000u); // red mask
w.Write(0x0000FF00u); // green mask
w.Write(0x000000FFu); // blue mask
w.Write(0xFF000000u); // alpha mask
// Color space type: LCS_sRGB
w.Write(0x73524742); // 'sRGB'
// CIEXYZTRIPLE endpoints (36 bytes zeroed)
w.Write(new byte[36]);
// Gamma RGB (12 bytes zeroed)
w.Write(new byte[12]);
// Pixel data: BMP is bottom-up, our buffer is top-down
for (int y = height - 1; y >= 0; y--)
{
int rowStart = y * width;
for (int x = 0; x < width; x++)
{
uint argb = pixels[rowStart + x];
byte a = (byte)(argb >> 24);
byte r = (byte)((argb >> 16) & 0xFF);
byte g = (byte)((argb >> 8) & 0xFF);
byte b = (byte)(argb & 0xFF);
w.Write(b);
w.Write(g);
w.Write(r);
w.Write(a);
}
}
}
}
```
### A.24. `Minint.Infrastructure/Export/GifExporter.cs`
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using Minint.Core.Services;
namespace Minint.Infrastructure.Export;
/// <summary>
/// Self-implemented GIF89a animated exporter.
/// Quantizes each ARGB frame to 256 colors (including transparent) using popularity.
/// Uses LZW compression as required by the GIF spec.
/// </summary>
public sealed class GifExporter : IGifExporter
{
public void Export(Stream stream, IReadOnlyList<(uint[] Pixels, uint DelayMs)> frames, int width, int height)
{
ArgumentNullException.ThrowIfNull(stream);
if (frames.Count == 0)
throw new ArgumentException("At least one frame is required.");
using var w = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
WriteGifHeader(w, width, height);
WriteNetscapeExtension(w); // infinite loop
foreach (var (pixels, delayMs) in frames)
{
var (palette, indices, transparentIndex) = Quantize(pixels, width * height);
int colorBits = GetColorBits(palette.Length);
int tableSize = 1 << colorBits;
WriteGraphicControlExtension(w, delayMs, transparentIndex);
WriteImageDescriptor(w, width, height, colorBits);
WriteColorTable(w, palette, tableSize);
WriteLzwImageData(w, indices, colorBits);
}
w.Write((byte)0x3B); // GIF trailer
}
private static void WriteGifHeader(BinaryWriter w, int width, int height)
{
w.Write("GIF89a"u8.ToArray());
w.Write((ushort)width);
w.Write((ushort)height);
// Global color table flag=0, color resolution=7, sort=0, gct size=0
w.Write((byte)0x70);
w.Write((byte)0); // background color index
w.Write((byte)0); // pixel aspect ratio
}
private static void WriteNetscapeExtension(BinaryWriter w)
{
w.Write((byte)0x21); // extension introducer
w.Write((byte)0xFF); // application extension
w.Write((byte)11); // block size
w.Write("NETSCAPE2.0"u8.ToArray());
w.Write((byte)3); // sub-block size
w.Write((byte)1); // sub-block ID
w.Write((ushort)0); // loop count: 0 = infinite
w.Write((byte)0); // block terminator
}
private static void WriteGraphicControlExtension(BinaryWriter w, uint delayMs, int transparentIndex)
{
w.Write((byte)0x21); // extension introducer
w.Write((byte)0xF9); // graphic control label
w.Write((byte)4); // block size
byte packed = (byte)(transparentIndex >= 0 ? 0x09 : 0x08);
// disposal method=2 (restore to background), no user input, transparent flag
w.Write(packed);
ushort delayCs = (ushort)(delayMs / 10); // GIF delay is in centiseconds
if (delayCs == 0 && delayMs > 0) delayCs = 1;
w.Write(delayCs);
w.Write((byte)(transparentIndex >= 0 ? transparentIndex : 0));
w.Write((byte)0); // block terminator
}
private static void WriteImageDescriptor(BinaryWriter w, int width, int height, int colorBits)
{
w.Write((byte)0x2C); // image separator
w.Write((ushort)0); // left
w.Write((ushort)0); // top
w.Write((ushort)width);
w.Write((ushort)height);
byte packed = (byte)(0x80 | (colorBits - 1)); // local color table, not interlaced
w.Write(packed);
}
private static void WriteColorTable(BinaryWriter w, byte[][] palette, int tableSize)
{
for (int i = 0; i < tableSize; i++)
{
if (i < palette.Length)
{
w.Write(palette[i][0]); // R
w.Write(palette[i][1]); // G
w.Write(palette[i][2]); // B
}
else
{
w.Write((byte)0);
w.Write((byte)0);
w.Write((byte)0);
}
}
}
#region LZW compression
private static void WriteLzwImageData(BinaryWriter w, byte[] indices, int colorBits)
{
int minCodeSize = Math.Max(colorBits, 2);
w.Write((byte)minCodeSize);
var output = new List<byte>();
LzwCompress(indices, minCodeSize, output);
int offset = 0;
while (offset < output.Count)
{
int blockLen = Math.Min(255, output.Count - offset);
w.Write((byte)blockLen);
for (int i = 0; i < blockLen; i++)
w.Write(output[offset + i]);
offset += blockLen;
}
w.Write((byte)0); // block terminator
}
private static void LzwCompress(byte[] indices, int minCodeSize, List<byte> output)
{
int clearCode = 1 << minCodeSize;
int eoiCode = clearCode + 1;
int codeSize = minCodeSize + 1;
int nextCode = eoiCode + 1;
int maxCode = (1 << codeSize) - 1;
var table = new Dictionary<(int Prefix, byte Suffix), int>();
int bitBuffer = 0;
int bitCount = 0;
void EmitCode(int code)
{
bitBuffer |= code << bitCount;
bitCount += codeSize;
while (bitCount >= 8)
{
output.Add((byte)(bitBuffer & 0xFF));
bitBuffer >>= 8;
bitCount -= 8;
}
}
void ResetTable()
{
table.Clear();
codeSize = minCodeSize + 1;
nextCode = eoiCode + 1;
maxCode = (1 << codeSize) - 1;
}
EmitCode(clearCode);
ResetTable();
if (indices.Length == 0)
{
EmitCode(eoiCode);
if (bitCount > 0) output.Add((byte)(bitBuffer & 0xFF));
return;
}
int prefix = indices[0];
for (int i = 1; i < indices.Length; i++)
{
byte suffix = indices[i];
var key = (prefix, suffix);
if (table.TryGetValue(key, out int existing))
{
prefix = existing;
}
else
{
EmitCode(prefix);
if (nextCode <= 4095)
{
table[key] = nextCode++;
if (nextCode > maxCode + 1 && codeSize < 12)
{
codeSize++;
maxCode = (1 << codeSize) - 1;
}
}
else
{
EmitCode(clearCode);
ResetTable();
}
prefix = suffix;
}
}
EmitCode(prefix);
EmitCode(eoiCode);
if (bitCount > 0) output.Add((byte)(bitBuffer & 0xFF));
}
#endregion
#region Quantization
/// <summary>
/// Quantizes ARGB pixels to max 256 palette entries.
/// Reserves index 0 for transparent if any pixel has alpha &lt; 128.
/// Uses popularity-based selection for opaque colors.
/// </summary>
private static (byte[][] Palette, byte[] Indices, int TransparentIndex) Quantize(uint[] argb, int count)
{
bool hasTransparency = false;
var colorCounts = new Dictionary<uint, int>();
for (int i = 0; i < count; i++)
{
uint px = argb[i];
byte a = (byte)(px >> 24);
if (a < 128)
{
hasTransparency = true;
continue;
}
uint opaque = px | 0xFF000000u;
colorCounts.TryGetValue(opaque, out int c);
colorCounts[opaque] = c + 1;
}
int transparentIndex = hasTransparency ? 0 : -1;
int maxColors = hasTransparency ? 255 : 256;
var sorted = new List<KeyValuePair<uint, int>>(colorCounts);
sorted.Sort((a, b) => b.Value.CompareTo(a.Value));
if (sorted.Count > maxColors)
sorted.RemoveRange(maxColors, sorted.Count - maxColors);
var palette = new List<byte[]>();
var colorToIndex = new Dictionary<uint, byte>();
if (hasTransparency)
{
palette.Add([0, 0, 0]); // transparent slot
}
foreach (var kv in sorted)
{
uint px = kv.Key;
byte idx = (byte)palette.Count;
palette.Add([
(byte)((px >> 16) & 0xFF),
(byte)((px >> 8) & 0xFF),
(byte)(px & 0xFF)
]);
colorToIndex[px] = idx;
}
if (palette.Count == 0)
palette.Add([0, 0, 0]);
var indices = new byte[count];
for (int i = 0; i < count; i++)
{
uint px = argb[i];
byte a = (byte)(px >> 24);
if (a < 128)
{
indices[i] = (byte)(transparentIndex >= 0 ? transparentIndex : 0);
continue;
}
uint opaque = px | 0xFF000000u;
if (colorToIndex.TryGetValue(opaque, out byte idx))
{
indices[i] = idx;
}
else
{
indices[i] = FindClosest(opaque, palette, hasTransparency ? 1 : 0);
}
}
return (palette.ToArray(), indices, transparentIndex);
}
private static byte FindClosest(uint argb, List<byte[]> palette, int startIdx)
{
byte r = (byte)((argb >> 16) & 0xFF);
byte g = (byte)((argb >> 8) & 0xFF);
byte b = (byte)(argb & 0xFF);
int bestIdx = startIdx;
int bestDist = int.MaxValue;
for (int i = startIdx; i < palette.Count; i++)
{
int dr = r - palette[i][0];
int dg = g - palette[i][1];
int db = b - palette[i][2];
int dist = dr * dr + dg * dg + db * db;
if (dist < bestDist)
{
bestDist = dist;
bestIdx = i;
}
}
return (byte)bestIdx;
}
private static int GetColorBits(int paletteCount)
{
int bits = 2;
while ((1 << bits) < paletteCount) bits++;
return Math.Min(bits, 8);
}
#endregion
}
```
### A.25. `Minint.Infrastructure/Serialization/MinintSerializer.cs`
```csharp
using System.Text;
using Minint.Core.Models;
using Minint.Core.Services;
namespace Minint.Infrastructure.Serialization;
/// <summary>
/// Self-implemented binary reader/writer for the .minint container format.
/// All multi-byte integers are little-endian. Strings are UTF-8 with a 1-byte length prefix.
/// </summary>
public sealed class MinintSerializer : IMinintSerializer
{
private static readonly byte[] Signature = "MININT"u8.ToArray();
private const ushort CurrentVersion = 1;
private const int ReservedBytes = 8;
private const int MaxNameLength = 255;
#region Write
public void Write(Stream stream, MinintContainer container)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentNullException.ThrowIfNull(container);
using var w = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
WriteHeader(w, container);
foreach (var doc in container.Documents)
WriteDocument(w, doc, container.Width, container.Height);
}
private static void WriteHeader(BinaryWriter w, MinintContainer c)
{
w.Write(Signature);
w.Write(CurrentVersion);
w.Write((uint)c.Width);
w.Write((uint)c.Height);
w.Write((uint)c.Documents.Count);
w.Write(new byte[ReservedBytes]);
}
private static void WriteDocument(BinaryWriter w, MinintDocument doc, int width, int height)
{
WritePrefixedString(w, doc.Name);
w.Write(doc.FrameDelayMs);
w.Write((uint)doc.Palette.Count);
foreach (var color in doc.Palette)
{
w.Write(color.R);
w.Write(color.G);
w.Write(color.B);
w.Write(color.A);
}
w.Write((uint)doc.Layers.Count);
int byteWidth = doc.IndexByteWidth;
foreach (var layer in doc.Layers)
WriteLayer(w, layer, byteWidth, width * height);
}
private static void WriteLayer(BinaryWriter w, MinintLayer layer, int byteWidth, int pixelCount)
{
WritePrefixedString(w, layer.Name);
w.Write(layer.IsVisible ? (byte)1 : (byte)0);
w.Write(layer.Opacity);
if (layer.Pixels.Length != pixelCount)
throw new InvalidOperationException(
$"Layer '{layer.Name}' has {layer.Pixels.Length} pixels, expected {pixelCount}.");
for (int i = 0; i < pixelCount; i++)
WriteIndex(w, layer.Pixels[i], byteWidth);
}
private static void WriteIndex(BinaryWriter w, int index, int byteWidth)
{
switch (byteWidth)
{
case 1:
w.Write((byte)index);
break;
case 2:
w.Write((ushort)index);
break;
case 3:
w.Write((byte)(index & 0xFF));
w.Write((byte)((index >> 8) & 0xFF));
w.Write((byte)((index >> 16) & 0xFF));
break;
case 4:
w.Write(index);
break;
}
}
private static void WritePrefixedString(BinaryWriter w, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
if (bytes.Length > MaxNameLength)
throw new InvalidOperationException(
$"String '{value}' exceeds max length of {MaxNameLength} UTF-8 bytes.");
w.Write((byte)bytes.Length);
w.Write(bytes);
}
#endregion
#region Read
public MinintContainer Read(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
using var r = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
var (width, height, docCount) = ReadHeader(r);
var container = new MinintContainer(width, height);
for (int i = 0; i < docCount; i++)
container.Documents.Add(ReadDocument(r, width, height));
return container;
}
private static (int Width, int Height, int DocCount) ReadHeader(BinaryReader r)
{
byte[] sig = ReadExact(r, Signature.Length, "file signature");
if (!sig.AsSpan().SequenceEqual(Signature))
throw new InvalidDataException(
"Invalid file signature. Expected 'MININT'.");
ushort version = r.ReadUInt16();
if (version != CurrentVersion)
throw new InvalidDataException(
$"Unsupported format version {version}. Only version {CurrentVersion} is supported.");
uint width = r.ReadUInt32();
uint height = r.ReadUInt32();
uint docCount = r.ReadUInt32();
if (width == 0 || height == 0)
throw new InvalidDataException("Width and height must be at least 1.");
if (width > 65_536 || height > 65_536)
throw new InvalidDataException(
$"Dimensions {width}x{height} exceed maximum supported size (65536).");
if (docCount == 0)
throw new InvalidDataException("Container must have at least 1 document.");
byte[] reserved = ReadExact(r, ReservedBytes, "reserved bytes");
for (int i = 0; i < reserved.Length; i++)
{
if (reserved[i] != 0)
break; // non-zero reserved bytes: tolerated for forward compat
}
return ((int)width, (int)height, (int)docCount);
}
private static MinintDocument ReadDocument(BinaryReader r, int width, int height)
{
string name = ReadPrefixedString(r);
uint frameDelay = r.ReadUInt32();
uint paletteCount = r.ReadUInt32();
if (paletteCount == 0)
throw new InvalidDataException("Palette must have at least 1 color.");
var palette = new List<RgbaColor>((int)paletteCount);
for (uint i = 0; i < paletteCount; i++)
{
byte cr = r.ReadByte();
byte cg = r.ReadByte();
byte cb = r.ReadByte();
byte ca = r.ReadByte();
palette.Add(new RgbaColor(cr, cg, cb, ca));
}
int byteWidth = GetIndexByteWidth((int)paletteCount);
uint layerCount = r.ReadUInt32();
if (layerCount == 0)
throw new InvalidDataException($"Document '{name}' must have at least 1 layer.");
var layers = new List<MinintLayer>((int)layerCount);
int pixelCount = width * height;
for (uint i = 0; i < layerCount; i++)
layers.Add(ReadLayer(r, byteWidth, pixelCount, (int)paletteCount));
return new MinintDocument(name, frameDelay, palette, layers);
}
private static MinintLayer ReadLayer(BinaryReader r, int byteWidth, int pixelCount, int paletteCount)
{
string name = ReadPrefixedString(r);
byte visByte = r.ReadByte();
if (visByte > 1)
throw new InvalidDataException(
$"Layer '{name}': invalid visibility flag {visByte} (expected 0 or 1).");
bool isVisible = visByte == 1;
byte opacity = r.ReadByte();
var pixels = new int[pixelCount];
for (int i = 0; i < pixelCount; i++)
{
int idx = ReadIndex(r, byteWidth);
if (idx < 0 || idx >= paletteCount)
throw new InvalidDataException(
$"Layer '{name}': pixel index {idx} at position {i} is out of palette range [0, {paletteCount}).");
pixels[i] = idx;
}
return new MinintLayer(name, isVisible, opacity, pixels);
}
private static int ReadIndex(BinaryReader r, int byteWidth)
{
return byteWidth switch
{
1 => r.ReadByte(),
2 => r.ReadUInt16(),
3 => r.ReadByte() | (r.ReadByte() << 8) | (r.ReadByte() << 16),
4 => r.ReadInt32(),
_ => throw new InvalidDataException($"Invalid index byte width: {byteWidth}")
};
}
private static string ReadPrefixedString(BinaryReader r)
{
byte len = r.ReadByte();
if (len == 0) return string.Empty;
byte[] bytes = ReadExact(r, len, "string data");
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Reads exactly <paramref name="count"/> bytes or throws on premature EOF.
/// </summary>
private static byte[] ReadExact(BinaryReader r, int count, string context)
{
byte[] buf = r.ReadBytes(count);
if (buf.Length < count)
throw new InvalidDataException(
$"Unexpected end of stream while reading {context} (expected {count} bytes, got {buf.Length}).");
return buf;
}
#endregion
#region Helpers
/// <summary>
/// Same logic as <see cref="MinintDocument.IndexByteWidth"/>,
/// usable when only the palette count is known (during deserialization).
/// </summary>
public static int GetIndexByteWidth(int paletteCount) => paletteCount switch
{
<= 255 => 1,
<= 65_535 => 2,
<= 16_777_215 => 3,
_ => 4
};
#endregion
}
```
### A.26. `Minint.Tests/CompositorTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Core.Services.Impl;
namespace Minint.Tests;
public class CompositorTests
{
private readonly Compositor _compositor = new();
[Fact]
public void Composite_EmptyLayer_AllTransparent()
{
var doc = new MinintDocument("test");
doc.Layers.Add(new MinintLayer("L1", 4));
uint[] result = _compositor.Composite(doc, 2, 2);
Assert.All(result, px => Assert.Equal(0u, px));
}
[Fact]
public void Composite_SingleOpaquePixel()
{
var doc = new MinintDocument("test");
var red = new RgbaColor(255, 0, 0, 255);
doc.EnsureColorCached(red);
var layer = new MinintLayer("L1", 4);
layer.Pixels[0] = 1;
doc.Layers.Add(layer);
uint[] result = _compositor.Composite(doc, 2, 2);
// ARGB packed as 0xAARRGGBB
uint expected = 0xFF_FF_00_00u;
Assert.Equal(expected, result[0]);
Assert.Equal(0u, result[1]); // rest is transparent
}
[Fact]
public void Composite_HiddenLayer_Ignored()
{
var doc = new MinintDocument("test");
doc.EnsureColorCached(new RgbaColor(0, 255, 0, 255));
var layer = new MinintLayer("L1", 4);
layer.Pixels[0] = 1;
layer.IsVisible = false;
doc.Layers.Add(layer);
uint[] result = _compositor.Composite(doc, 2, 2);
Assert.Equal(0u, result[0]);
}
[Fact]
public void Composite_TwoLayers_TopOverBottom()
{
var doc = new MinintDocument("test");
var red = new RgbaColor(255, 0, 0, 255);
var blue = new RgbaColor(0, 0, 255, 255);
int redIdx = doc.EnsureColorCached(red);
int blueIdx = doc.EnsureColorCached(blue);
var bottom = new MinintLayer("bottom", 1);
bottom.Pixels[0] = redIdx;
var top = new MinintLayer("top", 1);
top.Pixels[0] = blueIdx;
doc.Layers.Add(bottom);
doc.Layers.Add(top);
uint[] result = _compositor.Composite(doc, 1, 1);
// Blue on top, fully opaque, should overwrite red
Assert.Equal(0xFF_00_00_FFu, result[0]);
}
}
```
### A.27. `Minint.Tests/DrawingTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Core.Services.Impl;
namespace Minint.Tests;
public class DrawingTests
{
private readonly DrawingService _drawing = new();
[Fact]
public void ApplyBrush_Radius0_SetsSinglePixel()
{
var layer = new MinintLayer("L1", 9);
_drawing.ApplyBrush(layer, 1, 1, 0, 1, 3, 3);
Assert.Equal(1, layer.Pixels[1 * 3 + 1]);
Assert.Equal(0, layer.Pixels[0]); // (0,0) untouched
}
[Fact]
public void ApplyBrush_Radius1_SetsCircle()
{
var layer = new MinintLayer("L1", 25);
_drawing.ApplyBrush(layer, 2, 2, 1, 1, 5, 5);
// Center + 4 neighbors should be set
Assert.Equal(1, layer.Pixels[2 * 5 + 2]); // center
Assert.Equal(1, layer.Pixels[1 * 5 + 2]); // top
Assert.Equal(1, layer.Pixels[3 * 5 + 2]); // bottom
Assert.Equal(1, layer.Pixels[2 * 5 + 1]); // left
Assert.Equal(1, layer.Pixels[2 * 5 + 3]); // right
}
[Fact]
public void ApplyEraser_SetsToZero()
{
var layer = new MinintLayer("L1", 9);
Array.Fill(layer.Pixels, 5);
_drawing.ApplyEraser(layer, 1, 1, 0, 3, 3);
Assert.Equal(0, layer.Pixels[1 * 3 + 1]);
Assert.Equal(5, layer.Pixels[0]); // untouched
}
[Fact]
public void GetBrushMask_Radius0_SinglePixel()
{
var mask = _drawing.GetBrushMask(2, 2, 0, 5, 5);
Assert.Single(mask);
Assert.Equal((2, 2), mask[0]);
}
[Fact]
public void GetBrushMask_OutOfBounds_Clamped()
{
var mask = _drawing.GetBrushMask(0, 0, 2, 3, 3);
Assert.All(mask, p =>
{
Assert.InRange(p.X, 0, 2);
Assert.InRange(p.Y, 0, 2);
});
}
}
```
### A.28. `Minint.Tests/ExportTests.cs`
```csharp
using System.Text;
using Minint.Core.Models;
using Minint.Core.Services.Impl;
using Minint.Infrastructure.Export;
namespace Minint.Tests;
public class ExportTests
{
[Fact]
public void BmpExport_WritesValidBmp()
{
var exporter = new BmpExporter();
var pixels = new uint[] { 0xFF_FF_00_00, 0xFF_00_FF_00, 0xFF_00_00_FF, 0xFF_FF_FF_FF };
var ms = new MemoryStream();
exporter.Export(ms, pixels, 2, 2);
ms.Position = 0;
byte[] data = ms.ToArray();
Assert.True(data.Length > 0);
Assert.Equal((byte)'B', data[0]);
Assert.Equal((byte)'M', data[1]);
int fileSize = BitConverter.ToInt32(data, 2);
Assert.Equal(data.Length, fileSize);
}
[Fact]
public void GifExport_WritesValidGif()
{
var exporter = new GifExporter();
var frame1 = new uint[16]; // 4x4 transparent
var frame2 = new uint[16];
Array.Fill(frame2, 0xFF_FF_00_00u);
var frames = new List<(uint[] Pixels, uint DelayMs)>
{
(frame1, 100),
(frame2, 200),
};
var ms = new MemoryStream();
exporter.Export(ms, frames, 4, 4);
ms.Position = 0;
byte[] data = ms.ToArray();
Assert.True(data.Length > 0);
string sig = Encoding.ASCII.GetString(data, 0, 6);
Assert.Equal("GIF89a", sig);
Assert.Equal(0x3B, data[^1]); // GIF trailer
}
[Fact]
public void BmpExport_DimensionMismatch_Throws()
{
var exporter = new BmpExporter();
var pixels = new uint[3]; // does not match 2x2
Assert.Throws<ArgumentException>(() =>
{
var ms = new MemoryStream();
exporter.Export(ms, pixels, 2, 2);
});
}
}
```
### A.29. `Minint.Tests/FloodFillTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Core.Services.Impl;
namespace Minint.Tests;
public class FloodFillTests
{
private readonly FloodFillService _fill = new();
[Fact]
public void Fill_EmptyLayer_FillsAll()
{
var layer = new MinintLayer("L1", 9); // 3x3, all zeros
_fill.Fill(layer, 0, 0, 1, 3, 3);
Assert.All(layer.Pixels, px => Assert.Equal(1, px));
}
[Fact]
public void Fill_SameColor_NoOp()
{
var layer = new MinintLayer("L1", 4);
Array.Fill(layer.Pixels, 2);
_fill.Fill(layer, 0, 0, 2, 2, 2);
Assert.All(layer.Pixels, px => Assert.Equal(2, px));
}
[Fact]
public void Fill_Bounded_DoesNotCrossBorder()
{
// 3x3 grid with a wall:
// 0 0 0
// 1 1 1
// 0 0 0
var layer = new MinintLayer("L1", 9);
layer.Pixels[3] = 1; // (0,1)
layer.Pixels[4] = 1; // (1,1)
layer.Pixels[5] = 1; // (2,1)
_fill.Fill(layer, 0, 0, 2, 3, 3);
// Top row should be filled
Assert.Equal(2, layer.Pixels[0]);
Assert.Equal(2, layer.Pixels[1]);
Assert.Equal(2, layer.Pixels[2]);
// Wall untouched
Assert.Equal(1, layer.Pixels[3]);
// Bottom row untouched (blocked by wall)
Assert.Equal(0, layer.Pixels[6]);
}
}
```
### A.30. `Minint.Tests/FragmentServiceTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Core.Services.Impl;
namespace Minint.Tests;
public class FragmentServiceTests
{
private readonly FragmentService _fragment = new();
[Fact]
public void CopyFragment_SameDocument_CopiesPixels()
{
var doc = new MinintDocument("test");
var red = new RgbaColor(255, 0, 0, 255);
doc.EnsureColorCached(red);
var src = new MinintLayer("src", 16);
src.Pixels[0] = 1; // (0,0) = red
src.Pixels[1] = 1; // (1,0) = red
doc.Layers.Add(src);
var dst = new MinintLayer("dst", 16);
doc.Layers.Add(dst);
_fragment.CopyFragment(doc, 0, 0, 0, 2, 1, doc, 1, 2, 2, 4, 4);
Assert.Equal(1, dst.Pixels[2 * 4 + 2]); // (2,2)
Assert.Equal(1, dst.Pixels[2 * 4 + 3]); // (3,2)
}
[Fact]
public void CopyFragment_DifferentDocuments_MergesPalette()
{
var srcDoc = new MinintDocument("src");
var blue = new RgbaColor(0, 0, 255, 255);
int blueIdx = srcDoc.EnsureColorCached(blue);
var srcLayer = new MinintLayer("L1", 4);
srcLayer.Pixels[0] = blueIdx;
srcDoc.Layers.Add(srcLayer);
var dstDoc = new MinintDocument("dst");
var dstLayer = new MinintLayer("L1", 4);
dstDoc.Layers.Add(dstLayer);
_fragment.CopyFragment(srcDoc, 0, 0, 0, 1, 1, dstDoc, 0, 0, 0, 2, 2);
int dstBlueIdx = dstDoc.FindColorCached(blue);
Assert.True(dstBlueIdx > 0);
Assert.Equal(dstBlueIdx, dstLayer.Pixels[0]);
}
[Fact]
public void CopyFragment_TransparentPixels_Skipped()
{
var doc = new MinintDocument("test");
var src = new MinintLayer("src", 4); // all zeros (transparent)
doc.Layers.Add(src);
var dst = new MinintLayer("dst", 4);
Array.Fill(dst.Pixels, 0);
dst.Pixels[0] = 0; // explicitly 0
doc.Layers.Add(dst);
_fragment.CopyFragment(doc, 0, 0, 0, 2, 2, doc, 1, 0, 0, 2, 2);
Assert.Equal(0, dst.Pixels[0]); // stays transparent
}
}
```
### A.31. `Minint.Tests/ImageEffectsTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Core.Services.Impl;
namespace Minint.Tests;
public class ImageEffectsTests
{
private readonly ImageEffectsService _effects = new();
[Fact]
public void ApplyGrayscale_ConvertsColors()
{
var doc = new MinintDocument("test");
var red = new RgbaColor(255, 0, 0, 255);
doc.EnsureColorCached(red);
_effects.ApplyGrayscale(doc);
var gray = doc.Palette[1];
Assert.Equal(gray.R, gray.G);
Assert.Equal(gray.G, gray.B);
Assert.Equal(255, gray.A);
// BT.601: 0.299*255 ≈ 76
Assert.InRange(gray.R, 74, 78);
}
[Fact]
public void ApplyGrayscale_PreservesTransparentIndex()
{
var doc = new MinintDocument("test");
doc.EnsureColorCached(new RgbaColor(100, 200, 50, 255));
_effects.ApplyGrayscale(doc);
Assert.Equal(RgbaColor.Transparent, doc.Palette[0]);
}
[Fact]
public void ApplyContrast_IncreasesContrast()
{
var doc = new MinintDocument("test");
var midGray = new RgbaColor(128, 128, 128, 255);
var lightGray = new RgbaColor(192, 192, 192, 255);
doc.EnsureColorCached(midGray);
doc.EnsureColorCached(lightGray);
_effects.ApplyContrast(doc, 2.0);
// midGray (128) stays ~128: factor*(128-128)+128 = 128
Assert.InRange(doc.Palette[1].R, 126, 130);
// lightGray (192): factor*(192-128)+128 = 2*64+128 = 256 → clamped to 255
Assert.Equal(255, doc.Palette[2].R);
}
[Fact]
public void ApplyContrast_PreservesAlpha()
{
var doc = new MinintDocument("test");
doc.EnsureColorCached(new RgbaColor(100, 100, 100, 200));
_effects.ApplyContrast(doc, 1.5);
Assert.Equal(200, doc.Palette[1].A);
}
}
```
### A.32. `Minint.Tests/PatternGeneratorTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Core.Services;
using Minint.Core.Services.Impl;
namespace Minint.Tests;
public class PatternGeneratorTests
{
private readonly PatternGenerator _gen = new();
[Theory]
[InlineData(PatternType.Checkerboard)]
[InlineData(PatternType.HorizontalGradient)]
[InlineData(PatternType.VerticalGradient)]
[InlineData(PatternType.HorizontalStripes)]
[InlineData(PatternType.VerticalStripes)]
[InlineData(PatternType.ConcentricCircles)]
[InlineData(PatternType.Tile)]
public void Generate_AllTypes_ProducesValidDocument(PatternType type)
{
var colors = new[] { new RgbaColor(255, 0, 0, 255), new RgbaColor(0, 0, 255, 255) };
var doc = _gen.Generate(type, 16, 16, colors, 4, 4);
Assert.Equal($"Pattern ({type})", doc.Name);
Assert.Single(doc.Layers);
Assert.Equal(256, doc.Layers[0].Pixels.Length);
Assert.True(doc.Palette.Count >= 2);
}
[Fact]
public void Generate_Checkerboard_AlternatesColors()
{
var colors = new[] { new RgbaColor(255, 0, 0, 255), new RgbaColor(0, 255, 0, 255) };
var doc = _gen.Generate(PatternType.Checkerboard, 4, 4, colors, 2);
var layer = doc.Layers[0];
int topLeft = layer.Pixels[0];
int topRight = layer.Pixels[2]; // cellSize=2, so (2,0) is next cell
Assert.NotEqual(topLeft, topRight);
}
}
```
### A.33. `Minint.Tests/SerializerTests.cs`
```csharp
using Minint.Core.Models;
using Minint.Infrastructure.Serialization;
namespace Minint.Tests;
public class SerializerTests
{
private readonly MinintSerializer _serializer = new();
[Fact]
public void RoundTrip_EmptyDocument_PreservesStructure()
{
var container = new MinintContainer(32, 16);
container.AddNewDocument("Doc1");
var result = RoundTrip(container);
Assert.Equal(32, result.Width);
Assert.Equal(16, result.Height);
Assert.Single(result.Documents);
Assert.Equal("Doc1", result.Documents[0].Name);
Assert.Single(result.Documents[0].Layers);
Assert.Equal(32 * 16, result.Documents[0].Layers[0].Pixels.Length);
}
[Fact]
public void RoundTrip_MultipleDocuments_PreservesAll()
{
var container = new MinintContainer(8, 8);
var doc1 = container.AddNewDocument("Frame1");
doc1.FrameDelayMs = 200;
var doc2 = container.AddNewDocument("Frame2");
doc2.FrameDelayMs = 500;
var result = RoundTrip(container);
Assert.Equal(2, result.Documents.Count);
Assert.Equal("Frame1", result.Documents[0].Name);
Assert.Equal(200u, result.Documents[0].FrameDelayMs);
Assert.Equal("Frame2", result.Documents[1].Name);
Assert.Equal(500u, result.Documents[1].FrameDelayMs);
}
[Fact]
public void RoundTrip_PaletteAndPixels_Preserved()
{
var container = new MinintContainer(4, 4);
var doc = container.AddNewDocument("Test");
var red = new RgbaColor(255, 0, 0, 255);
doc.EnsureColorCached(red);
var layer = doc.Layers[0];
layer.Pixels[0] = 1; // red
var result = RoundTrip(container);
var rdoc = result.Documents[0];
Assert.Equal(2, rdoc.Palette.Count); // transparent + red
Assert.Equal(RgbaColor.Transparent, rdoc.Palette[0]);
Assert.Equal(red, rdoc.Palette[1]);
Assert.Equal(1, rdoc.Layers[0].Pixels[0]);
Assert.Equal(0, rdoc.Layers[0].Pixels[1]);
}
[Fact]
public void RoundTrip_LayerProperties_Preserved()
{
var container = new MinintContainer(2, 2);
var doc = container.AddNewDocument("Test");
doc.Layers[0].Name = "Background";
doc.Layers[0].IsVisible = false;
doc.Layers[0].Opacity = 128;
var result = RoundTrip(container);
var layer = result.Documents[0].Layers[0];
Assert.Equal("Background", layer.Name);
Assert.False(layer.IsVisible);
Assert.Equal(128, layer.Opacity);
}
[Fact]
public void RoundTrip_LargePalette_Uses2ByteIndices()
{
var container = new MinintContainer(2, 2);
var doc = container.AddNewDocument("BigPalette");
for (int i = 0; i < 300; i++)
doc.EnsureColorCached(new RgbaColor((byte)(i % 256), (byte)(i / 256), 0, 255));
Assert.Equal(2, doc.IndexByteWidth);
int lastIdx = doc.Palette.Count - 1;
doc.Layers[0].Pixels[0] = lastIdx;
var result = RoundTrip(container);
Assert.Equal(lastIdx, result.Documents[0].Layers[0].Pixels[0]);
}
[Fact]
public void Read_InvalidSignature_Throws()
{
var ms = new MemoryStream("BADDATA"u8.ToArray());
Assert.Throws<InvalidDataException>(() => _serializer.Read(ms));
}
[Fact]
public void Read_TruncatedStream_Throws()
{
var ms = new MemoryStream("MINI"u8.ToArray());
Assert.Throws<InvalidDataException>(() => _serializer.Read(ms));
}
private MinintContainer RoundTrip(MinintContainer container)
{
var ms = new MemoryStream();
_serializer.Write(ms, container);
ms.Position = 0;
return _serializer.Read(ms);
}
}
```
### A.34. `Minint/App.axaml.cs`
```csharp
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using Minint.ViewModels;
using Minint.Views;
namespace Minint;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
}
```
### A.35. `Minint/Controls/EditableTextBlock.cs`
```csharp
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
namespace Minint.Controls;
/// <summary>
/// Shows a TextBlock by default; switches to an inline TextBox on double-click.
/// Commits on Enter or focus loss, cancels on Escape.
/// </summary>
public class EditableTextBlock : Control
{
public static readonly StyledProperty<string> TextProperty =
AvaloniaProperty.Register<EditableTextBlock, string>(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;
}
}
```
### A.36. `Minint/Controls/PixelCanvas.cs`
```csharp
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Minint.Core.Models;
using Minint.ViewModels;
namespace Minint.Controls;
public class PixelCanvas : Control
{
#region Styled Properties
public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty =
AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap));
public static readonly StyledProperty<bool> ShowGridProperty =
AvaloniaProperty.Register<PixelCanvas, bool>(nameof(ShowGrid), defaultValue: false);
public WriteableBitmap? SourceBitmap
{
get => GetValue(SourceBitmapProperty);
set => SetValue(SourceBitmapProperty, value);
}
public bool ShowGrid
{
get => GetValue(ShowGridProperty);
set => SetValue(ShowGridProperty, value);
}
#endregion
#region Events for tool interaction
public event Action<int, int>? ToolDown;
public event Action<int, int>? ToolDrag;
public event Action<(int X, int Y)?>? CursorPixelChanged;
public Func<List<(int X, int Y)>?>? GetPreviewMask { get; set; }
// Selection events
public event Action<int, int>? SelectionStart;
public event Action<int, int>? SelectionUpdate;
public event Action<int, int>? SelectionEnd;
// Paste events
public event Action<int, int>? PasteMoved;
public event Action? PasteCommitted;
public event Action? PasteCancelled;
/// <summary>Provides the current EditorViewModel for reading selection/paste state during render.</summary>
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;
private int _lastBitmapWidth;
private int _lastBitmapHeight;
private (int X, int Y)? _lastCursorPixel;
private Point? _lastScreenPos;
private ScrollBar? _hScrollBar;
private ScrollBar? _vScrollBar;
private bool _suppressScrollSync;
private const double ScrollPixelsPerTick = 20.0;
public Viewport Viewport => _viewport;
static PixelCanvas()
{
AffectsRender<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true);
}
public PixelCanvas()
{
ClipToBounds = true;
}
public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
{
if (_hScrollBar is not null) _hScrollBar.ValueChanged -= OnHScrollChanged;
if (_vScrollBar is not null) _vScrollBar.ValueChanged -= OnVScrollChanged;
_hScrollBar = horizontal;
_vScrollBar = vertical;
_hScrollBar.ValueChanged += OnHScrollChanged;
_vScrollBar.ValueChanged += OnVScrollChanged;
}
#region Rendering
public override void Render(DrawingContext context)
{
base.Render(context);
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
var bmp = SourceBitmap;
if (bmp is null) return;
int imgW = bmp.PixelSize.Width;
int imgH = bmp.PixelSize.Height;
if (!_viewportInitialized)
{
_viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
_viewportInitialized = true;
}
DrawCheckerboard(context, imgW, imgH);
var destRect = _viewport.ImageScreenRect(imgW, imgH);
var srcRect = new Rect(0, 0, imgW, imgH);
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
context.DrawImage(bmp, srcRect, destRect);
if (ShowGrid)
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);
}
private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
{
var rect = _viewport.ImageScreenRect(imgW, imgH);
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
var visible = rect.Intersect(clip);
if (visible.Width <= 0 || visible.Height <= 0) return;
const int checkerSize = 8;
var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
using (context.PushClip(visible))
{
context.FillRectangle(light, visible);
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
for (double y = startY; y < visible.Bottom; y += checkerSize)
{
for (double x = startX; x < visible.Right; x += checkerSize)
{
int col = (int)((x - rect.X) / checkerSize);
int row = (int)((y - rect.Y) / checkerSize);
if ((col + row) % 2 == 1)
context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize));
}
}
}
}
private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
{
double zoom = _viewport.Zoom;
if (zoom < 4) return;
var pen = new Pen(Brushes.Black, 1);
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
var imgRect = _viewport.ImageScreenRect(imgW, imgH);
var visible = imgRect.Intersect(clip);
if (visible.Width <= 0 || visible.Height <= 0) return;
var (startPx, startPy) = _viewport.ScreenToPixel(visible.X, visible.Y);
var (endPx, endPy) = _viewport.ScreenToPixel(visible.Right, visible.Bottom);
startPx = Math.Max(0, startPx);
startPy = Math.Max(0, startPy);
endPx = Math.Min(imgW, endPx + 1);
endPy = Math.Min(imgH, endPy + 1);
using (context.PushClip(visible))
{
for (int px = startPx; px <= endPx; px++)
{
var (sx, _) = _viewport.PixelToScreen(px, 0);
double x = Math.Floor(sx) + 0.5;
context.DrawLine(pen, new Point(x, visible.Top), new Point(x, visible.Bottom));
}
for (int py = startPy; py <= endPy; py++)
{
var (_, sy) = _viewport.PixelToScreen(0, py);
double y = Math.Floor(sy) + 0.5;
context.DrawLine(pen, new Point(visible.Left, y), new Point(visible.Right, y));
}
}
}
private void DrawToolPreview(DrawingContext context, int imgW, int imgH)
{
var mask = GetPreviewMask?.Invoke();
if (mask is null || mask.Count == 0) return;
double zoom = _viewport.Zoom;
var previewBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255));
var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(160, 255, 255, 255)), 1);
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
using (context.PushClip(clip))
{
foreach (var (px, py) in mask)
{
var (sx, sy) = _viewport.PixelToScreen(px, py);
context.FillRectangle(previewBrush, new Rect(sx, sy, zoom, zoom));
}
if (mask.Count > 0)
{
int minX = mask[0].X, maxX = mask[0].X;
int minY = mask[0].Y, maxY = mask[0].Y;
foreach (var (px, py) in mask)
{
if (px < minX) minX = px;
if (px > maxX) maxX = px;
if (py < minY) minY = py;
if (py > maxY) maxY = py;
}
var (ox, oy) = _viewport.PixelToScreen(minX, minY);
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
private void SyncScrollBars(int imgW, int imgH)
{
if (_hScrollBar is null || _vScrollBar is null) return;
_suppressScrollSync = true;
var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
_hScrollBar.Minimum = -hMax;
_hScrollBar.Maximum = -hMin;
_hScrollBar.Value = -hVal;
_hScrollBar.ViewportSize = hView;
var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
_vScrollBar.Minimum = -vMax;
_vScrollBar.Maximum = -vMin;
_vScrollBar.Value = -vVal;
_vScrollBar.ViewportSize = vView;
_suppressScrollSync = false;
}
private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (_suppressScrollSync) return;
var (imgW, imgH) = GetImageSize();
_viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height);
RecalcCursorPixel();
InvalidateVisual();
}
private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (_suppressScrollSync) return;
var (imgW, imgH) = GetImageSize();
_viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height);
RecalcCursorPixel();
InvalidateVisual();
}
#endregion
#region Mouse Input
private (int W, int H) GetImageSize()
{
var bmp = SourceBitmap;
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
}
private (int X, int Y)? ScreenToPixelClamped(Point pos)
{
var (imgW, imgH) = GetImageSize();
if (imgW == 0) return null;
var (px, py) = _viewport.ScreenToPixel(pos.X, pos.Y);
if (px < 0 || px >= imgW || py < 0 || py >= imgH)
return null;
return (px, py);
}
private void RecalcCursorPixel()
{
if (_lastScreenPos is null) return;
var pixel = ScreenToPixelClamped(_lastScreenPos.Value);
if (pixel != _lastCursorPixel)
{
_lastCursorPixel = pixel;
CursorPixelChanged?.Invoke(pixel);
}
}
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
base.OnPointerWheelChanged(e);
var (imgW, imgH) = GetImageSize();
if (imgW == 0) return;
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
if (ctrl)
{
var pos = e.GetPosition(this);
_viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y, imgW, imgH, Bounds.Width, Bounds.Height);
}
else
{
double dx = e.Delta.X * ScrollPixelsPerTick;
double dy = e.Delta.Y * ScrollPixelsPerTick;
if (shift && Math.Abs(e.Delta.X) < 0.001)
{
dx = dy;
dy = 0;
}
_viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
}
RecalcCursorPixel();
InvalidateVisual();
e.Handled = true;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var props = e.GetCurrentPoint(this).Properties;
if (props.IsMiddleButtonPressed)
{
_isPanning = true;
_panStart = e.GetPosition(this);
_panStartOffsetX = _viewport.OffsetX;
_panStartOffsetY = _viewport.OffsetY;
e.Handled = true;
return;
}
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)
{
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)
{
base.OnPointerMoved(e);
var pos = e.GetPosition(this);
_lastScreenPos = pos;
if (_isPanning)
{
var (imgW, imgH) = GetImageSize();
_viewport.SetOffset(
_panStartOffsetX + (pos.X - _panStart.X),
_panStartOffsetY + (pos.Y - _panStart.Y),
imgW, imgH, Bounds.Width, Bounds.Height);
RecalcCursorPixel();
InvalidateVisual();
e.Handled = true;
return;
}
var pixel = ScreenToPixelClamped(pos);
if (pixel != _lastCursorPixel)
{
_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)
{
ToolDrag?.Invoke(pixel.Value.X, pixel.Value.Y);
e.Handled = true;
}
else
{
InvalidateVisual();
}
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle)
{
_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;
e.Handled = true;
}
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
_lastScreenPos = null;
if (_lastCursorPixel is not null)
{
_lastCursorPixel = null;
CursorPixelChanged?.Invoke(null);
InvalidateVisual();
}
}
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)
{
base.OnPropertyChanged(change);
if (change.Property == SourceBitmapProperty)
{
var bmp = change.GetNewValue<WriteableBitmap?>();
int w = bmp?.PixelSize.Width ?? 0;
int h = bmp?.PixelSize.Height ?? 0;
if (w != _lastBitmapWidth || h != _lastBitmapHeight)
{
_lastBitmapWidth = w;
_lastBitmapHeight = h;
_viewportInitialized = false;
}
}
}
}
```
### A.37. `Minint/Controls/Viewport.cs`
```csharp
using System;
using Avalonia;
namespace Minint.Controls;
/// <summary>
/// Manages zoom level and pan offset for the pixel canvas.
/// Provides screen↔pixel coordinate transforms.
/// </summary>
public sealed class Viewport
{
public double Zoom { get; set; } = 1.0;
public double OffsetX { get; set; }
public double OffsetY { get; set; }
public const double MinZoom = 0.25;
public const double MaxZoom = 128.0;
/// <summary>
/// Zoom base per 1.0 unit of wheel delta. Actual factor = Pow(base, |delta|).
/// Touchpad nudge (delta ~0.1) → ~1.01×, mouse tick (delta 1.0) → 1.10×, fast (3.0) → 1.33×.
/// </summary>
private const double ZoomBase = 1.10;
public (int X, int Y) ScreenToPixel(double screenX, double screenY) =>
((int)Math.Floor((screenX - OffsetX) / Zoom),
(int)Math.Floor((screenY - OffsetY) / Zoom));
public (double X, double Y) PixelToScreen(int pixelX, int pixelY) =>
(pixelX * Zoom + OffsetX,
pixelY * Zoom + OffsetY);
public Rect ImageScreenRect(int imageWidth, int imageHeight) =>
new(OffsetX, OffsetY, imageWidth * Zoom, imageHeight * Zoom);
/// <summary>
/// Zooms keeping the point under cursor fixed.
/// Uses the actual magnitude of <paramref name="delta"/> for proportional zoom.
/// </summary>
public void ZoomAtPoint(double screenX, double screenY, double delta,
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
double absDelta = Math.Abs(delta);
double factor = delta > 0 ? Math.Pow(ZoomBase, absDelta) : 1.0 / Math.Pow(ZoomBase, absDelta);
double newZoom = Math.Clamp(Zoom * factor, MinZoom, MaxZoom);
if (Math.Abs(newZoom - Zoom) < 1e-12) return;
double pixelX = (screenX - OffsetX) / Zoom;
double pixelY = (screenY - OffsetY) / Zoom;
Zoom = newZoom;
OffsetX = screenX - pixelX * Zoom;
OffsetY = screenY - pixelY * Zoom;
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
}
/// <summary>
/// Pans by screen-space delta, then clamps so the image can't be scrolled out of view.
/// </summary>
public void Pan(double deltaX, double deltaY,
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
OffsetX += deltaX;
OffsetY += deltaY;
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
}
/// <summary>
/// Sets offset directly (e.g. from middle-mouse drag), then clamps.
/// </summary>
public void SetOffset(double offsetX, double offsetY,
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
OffsetX = offsetX;
OffsetY = offsetY;
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
}
/// <summary>
/// Ensures at least <c>minVisible</c> pixels of the image remain on screen on each edge.
/// </summary>
public void ClampOffset(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
double extentW = imageWidth * Zoom;
double extentH = imageHeight * Zoom;
double minVisH = Math.Max(32, Math.Min(controlWidth, extentW) * 0.10);
double minVisV = Math.Max(32, Math.Min(controlHeight, extentH) * 0.10);
// Image right edge must be >= minVisH from left of control
// Image left edge must be <= controlWidth - minVisH from left
OffsetX = Math.Clamp(OffsetX, minVisH - extentW, controlWidth - minVisH);
OffsetY = Math.Clamp(OffsetY, minVisV - extentH, controlHeight - minVisV);
}
public void FitToView(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
if (imageWidth <= 0 || imageHeight <= 0 || controlWidth <= 0 || controlHeight <= 0)
return;
double scaleX = controlWidth / imageWidth;
double scaleY = controlHeight / imageHeight;
Zoom = Math.Max(1.0, Math.Floor(Math.Min(scaleX, scaleY)));
OffsetX = (controlWidth - imageWidth * Zoom) / 2.0;
OffsetY = (controlHeight - imageHeight * Zoom) / 2.0;
}
public (double Min, double Max, double Value, double ViewportSize)
GetScrollInfo(int imageSize, double controlSize, double offset)
{
double extent = imageSize * Zoom;
double minVis = Math.Max(32, Math.Min(controlSize, extent) * 0.10);
double min = minVis - extent;
double max = controlSize - minVis;
double viewportSize = Math.Min(controlSize, extent);
return (min, max, offset, viewportSize);
}
}
```
### A.38. `Minint/Program.cs`
```csharp
using Avalonia;
using System;
using System.IO;
using Minint.Core.Models;
using Minint.Core.Services.Impl;
using Minint.Infrastructure.Serialization;
namespace Minint;
sealed class Program
{
[STAThread]
public static void Main(string[] args)
{
// TODO: remove --test branch after verification
if (args.Length > 0 && args[0] == "--test")
{
RunRoundTripTest();
RunCompositorTest();
RunPaletteServiceTest();
return;
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.With(new X11PlatformOptions { OverlayPopups = true });
// TODO: temporary tests — remove after verification stages.
private static void RunRoundTripTest()
{
Console.WriteLine("=== Minint Round-Trip Test ===\n");
var container = new MinintContainer(8, 4);
var doc1 = container.AddNewDocument("Frame 1");
doc1.FrameDelayMs = 200;
doc1.Palette.Add(new RgbaColor(255, 0, 0, 255)); // idx 1 = red
doc1.Palette.Add(new RgbaColor(0, 255, 0, 255)); // idx 2 = green
doc1.Palette.Add(new RgbaColor(0, 0, 255, 128)); // idx 3 = semi-transparent blue
var layer1 = doc1.Layers[0];
for (int i = 0; i < layer1.Pixels.Length; i++)
layer1.Pixels[i] = i % 4; // cycle 0,1,2,3
doc1.Layers.Add(new MinintLayer("Overlay", container.PixelCount));
var layer2 = doc1.Layers[1];
layer2.Opacity = 128;
layer2.Pixels[0] = 3;
layer2.Pixels[5] = 2;
var doc2 = container.AddNewDocument("Frame 2");
doc2.FrameDelayMs = 150;
doc2.Palette.Add(new RgbaColor(255, 255, 0, 255)); // idx 1 = yellow
var layer3 = doc2.Layers[0];
for (int i = 0; i < layer3.Pixels.Length; i++)
layer3.Pixels[i] = i % 2;
Console.WriteLine($"Original: {container.Width}x{container.Height}, {container.Documents.Count} docs");
Console.WriteLine($" Doc1: palette={doc1.Palette.Count} colors, layers={doc1.Layers.Count}, indexWidth={doc1.IndexByteWidth}");
Console.WriteLine($" Doc2: palette={doc2.Palette.Count} colors, layers={doc2.Layers.Count}, indexWidth={doc2.IndexByteWidth}");
var serializer = new MinintSerializer();
using var ms = new MemoryStream();
serializer.Write(ms, container);
byte[] data = ms.ToArray();
Console.WriteLine($"\nSerialized: {data.Length} bytes");
Console.WriteLine($" Signature: {System.Text.Encoding.ASCII.GetString(data, 0, 6)}");
ms.Position = 0;
var loaded = serializer.Read(ms);
Assert(loaded.Width == container.Width, "Width mismatch");
Assert(loaded.Height == container.Height, "Height mismatch");
Assert(loaded.Documents.Count == container.Documents.Count, "Document count mismatch");
for (int d = 0; d < container.Documents.Count; d++)
{
var orig = container.Documents[d];
var copy = loaded.Documents[d];
Assert(copy.Name == orig.Name, $"Doc[{d}] name mismatch");
Assert(copy.FrameDelayMs == orig.FrameDelayMs, $"Doc[{d}] frameDelay mismatch");
Assert(copy.Palette.Count == orig.Palette.Count, $"Doc[{d}] palette count mismatch");
for (int c = 0; c < orig.Palette.Count; c++)
Assert(copy.Palette[c] == orig.Palette[c], $"Doc[{d}] palette[{c}] mismatch");
Assert(copy.Layers.Count == orig.Layers.Count, $"Doc[{d}] layer count mismatch");
for (int l = 0; l < orig.Layers.Count; l++)
{
var oLayer = orig.Layers[l];
var cLayer = copy.Layers[l];
Assert(cLayer.Name == oLayer.Name, $"Doc[{d}].Layer[{l}] name mismatch");
Assert(cLayer.IsVisible == oLayer.IsVisible, $"Doc[{d}].Layer[{l}] visibility mismatch");
Assert(cLayer.Opacity == oLayer.Opacity, $"Doc[{d}].Layer[{l}] opacity mismatch");
Assert(cLayer.Pixels.Length == oLayer.Pixels.Length, $"Doc[{d}].Layer[{l}] pixel count mismatch");
for (int p = 0; p < oLayer.Pixels.Length; p++)
Assert(cLayer.Pixels[p] == oLayer.Pixels[p],
$"Doc[{d}].Layer[{l}].Pixels[{p}] mismatch: expected {oLayer.Pixels[p]}, got {cLayer.Pixels[p]}");
}
}
Console.WriteLine("\n✓ All assertions passed — round-trip is correct!");
// Test invalid signature
Console.Write("\nTest: invalid signature... ");
data[0] = (byte)'X';
try
{
serializer.Read(new MemoryStream(data));
Console.WriteLine("FAIL (no exception)");
}
catch (InvalidDataException)
{
Console.WriteLine("OK (InvalidDataException)");
}
data[0] = (byte)'M'; // restore
// Test truncated stream
Console.Write("Test: truncated stream... ");
try
{
serializer.Read(new MemoryStream(data, 0, 10));
Console.WriteLine("FAIL (no exception)");
}
catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException)
{
Console.WriteLine($"OK ({ex.GetType().Name})");
}
Console.WriteLine("\n=== All tests passed ===");
}
private static void RunCompositorTest()
{
Console.WriteLine("\n=== Compositor Test ===\n");
var compositor = new Compositor();
const int W = 2, H = 2;
// Document: 2 layers on a 2x2 canvas
var doc = new MinintDocument("test");
doc.Palette.Add(new RgbaColor(255, 0, 0, 255)); // idx 1 = opaque red
doc.Palette.Add(new RgbaColor(0, 0, 255, 128)); // idx 2 = semi-transparent blue
// Bottom layer: all red
var bottom = new MinintLayer("bottom", W * H);
for (int i = 0; i < bottom.Pixels.Length; i++)
bottom.Pixels[i] = 1;
doc.Layers.Add(bottom);
// Top layer: pixel[0] = semi-blue, rest transparent
var top = new MinintLayer("top", W * H);
top.Pixels[0] = 2;
doc.Layers.Add(top);
uint[] result = compositor.Composite(doc, W, H);
// Pixel [1],[2],[3]: only bottom visible → opaque red → 0xFFFF0000 (ARGB)
uint opaqueRed = 0xFF_FF_00_00;
Assert(result[1] == opaqueRed, $"Pixel[1]: expected {opaqueRed:X8}, got {result[1]:X8}");
Assert(result[2] == opaqueRed, $"Pixel[2]: expected {opaqueRed:X8}, got {result[2]:X8}");
Assert(result[3] == opaqueRed, $"Pixel[3]: expected {opaqueRed:X8}, got {result[3]:X8}");
// Pixel [0]: red(255,0,0,255) under blue(0,0,255,128)
// srcA=128, dstA=255 → outA = 128 + 255*(255-128)/255 = 128+127 = 255
// outR = (0*128 + 255*255*(127)/255) / 255 = (0 + 255*127)/255 = 127
// outG = 0
// outB = (255*128 + 0) / 255 = 128
uint blended = result[0];
byte bA = (byte)(blended >> 24);
byte bR = (byte)((blended >> 16) & 0xFF);
byte bG = (byte)((blended >> 8) & 0xFF);
byte bB = (byte)(blended & 0xFF);
Console.WriteLine($" Blended pixel[0]: A={bA} R={bR} G={bG} B={bB}");
Assert(bA == 255, $"Pixel[0] A: expected 255, got {bA}");
Assert(bR >= 125 && bR <= 129, $"Pixel[0] R: expected ~127, got {bR}");
Assert(bG == 0, $"Pixel[0] G: expected 0, got {bG}");
Assert(bB >= 126 && bB <= 130, $"Pixel[0] B: expected ~128, got {bB}");
// Test hidden layer: hide top, result should be all red
top.IsVisible = false;
uint[] result2 = compositor.Composite(doc, W, H);
Assert(result2[0] == opaqueRed, $"Hidden top: Pixel[0] should be red, got {result2[0]:X8}");
// Test layer opacity=0: make top visible but opacity=0
top.IsVisible = true;
top.Opacity = 0;
uint[] result3 = compositor.Composite(doc, W, H);
Assert(result3[0] == opaqueRed, $"Opacity 0: Pixel[0] should be red, got {result3[0]:X8}");
// Test single transparent layer
var emptyDoc = new MinintDocument("empty");
emptyDoc.Layers.Add(new MinintLayer("bg", W * H));
uint[] result4 = compositor.Composite(emptyDoc, W, H);
Assert(result4[0] == 0, $"Empty layer: Pixel[0] should be 0x00000000, got {result4[0]:X8}");
Console.WriteLine("✓ Compositor tests passed!");
}
private static void RunPaletteServiceTest()
{
Console.WriteLine("\n=== PaletteService Test ===\n");
var svc = new PaletteService();
var doc = new MinintDocument("test");
// Palette starts with [Transparent]
Assert(doc.Palette.Count == 1, "Initial palette should have 1 entry");
// EnsureColor: new color
var red = new RgbaColor(255, 0, 0, 255);
int redIdx = svc.EnsureColor(doc, red);
Assert(redIdx == 1, $"Red index: expected 1, got {redIdx}");
Assert(doc.Palette.Count == 2, $"Palette count after red: expected 2, got {doc.Palette.Count}");
// EnsureColor: same color → same index
int redIdx2 = svc.EnsureColor(doc, red);
Assert(redIdx2 == 1, $"Red re-ensure: expected 1, got {redIdx2}");
Assert(doc.Palette.Count == 2, "Palette should not grow on duplicate");
// FindColor
Assert(svc.FindColor(doc, red) == 1, "FindColor red");
Assert(svc.FindColor(doc, new RgbaColor(0, 0, 0, 255)) == -1, "FindColor missing");
// Compact: add unused color, then compact
var green = new RgbaColor(0, 255, 0, 255);
int greenIdx = svc.EnsureColor(doc, green); // idx 2
doc.Layers.Add(new MinintLayer("L", 4));
doc.Layers[0].Pixels[0] = 1; // red used
doc.Layers[0].Pixels[1] = 0; // transparent used
// green (idx 2) is NOT used by any pixel
Console.WriteLine($" Before compact: palette has {doc.Palette.Count} colors (green at idx {greenIdx})");
svc.CompactPalette(doc);
Console.WriteLine($" After compact: palette has {doc.Palette.Count} colors");
Assert(doc.Palette.Count == 2, $"After compact: expected 2 colors, got {doc.Palette.Count}");
Assert(doc.Palette[0] == RgbaColor.Transparent, "Palette[0] should be transparent");
Assert(doc.Palette[1] == red, $"Palette[1] should be red, got {doc.Palette[1]}");
Assert(doc.Layers[0].Pixels[0] == 1, "Pixel[0] should still map to red (idx 1)");
Console.WriteLine("✓ PaletteService tests passed!");
}
private static void Assert(bool condition, string message)
{
if (!condition)
throw new Exception($"Assertion failed: {message}");
}
}
```
### A.39. `Minint/ViewLocator.cs`
```csharp
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Minint.ViewModels;
namespace Minint;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
```
### A.40. `Minint/ViewModels/EditorViewModel.cs`
```csharp
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
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;
var doc = ActiveDocument;
int idx = Container.Documents.IndexOf(doc);
if (idx <= 0) return;
(Container.Documents[idx], Container.Documents[idx - 1]) = (Container.Documents[idx - 1], Container.Documents[idx]);
SyncDocumentsList();
ReselectDocument(doc);
}
[RelayCommand]
private void MoveDocumentDown()
{
if (Container is null || ActiveDocument is null) return;
var doc = ActiveDocument;
int idx = Container.Documents.IndexOf(doc);
if (idx < 0 || idx >= Container.Documents.Count - 1) return;
(Container.Documents[idx], Container.Documents[idx + 1]) = (Container.Documents[idx + 1], Container.Documents[idx]);
SyncDocumentsList();
ReselectDocument(doc);
}
private void ReselectDocument(MinintDocument doc)
{
// Force SelectedItem rebinding after list rebuild even when the selected object is the same reference.
_suppressDocumentSync = true;
ActiveDocument = null;
ActiveDocument = doc;
_suppressDocumentSync = false;
SyncLayersAndCanvas(doc);
}
#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; }
[RelayCommand]
private void ToggleGrid() => ShowGrid = !ShowGrid;
#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;
if (ActiveDocument is not null)
SyncLayersAndCanvas(ActiveDocument);
}
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;
}
```
### A.41. `Minint/ViewModels/MainWindowViewModel.cs`
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Minint.Core.Services;
using Minint.Core.Services.Impl;
using Minint.Infrastructure.Export;
using Minint.Infrastructure.Serialization;
using Minint.Views;
namespace Minint.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly MinintSerializer _serializer = new();
private readonly IImageEffectsService _effects = new ImageEffectsService();
private readonly IPatternGenerator _patternGen = new PatternGenerator();
private readonly IBmpExporter _bmpExporter = new BmpExporter();
private readonly IGifExporter _gifExporter = new GifExporter();
private readonly ICompositor _compositor = new Compositor();
private static readonly FilePickerFileType MinintFileType = new("Minint Files")
{
Patterns = ["*.minint"],
};
private static readonly FilePickerFileType BmpFileType = new("BMP Image")
{
Patterns = ["*.bmp"],
};
private static readonly FilePickerFileType GifFileType = new("GIF Animation")
{
Patterns = ["*.gif"],
};
[ObservableProperty]
private EditorViewModel _editor = new();
[ObservableProperty]
private string _statusText = "Ready";
public TopLevel? Owner { get; set; }
#region File commands
[RelayCommand]
private async Task NewFileAsync()
{
if (Owner is not Window window) return;
var dialog = new NewContainerDialog();
var result = await dialog.ShowDialog<bool?>(window);
if (result != true) return;
int w = dialog.CanvasWidth;
int h = dialog.CanvasHeight;
Editor.NewContainer(w, h);
StatusText = $"New {w}×{h} 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}";
}
}
#endregion
#region Effects (A1, A2)
[RelayCommand]
private async Task ApplyContrastAsync()
{
if (Editor.ActiveDocument is null || Owner is not Window window) return;
var dialog = new ContrastDialog();
var result = await dialog.ShowDialog<bool?>(window);
if (result != true) return;
_effects.ApplyContrast(Editor.ActiveDocument, dialog.Factor);
Editor.RefreshCanvas();
StatusText = $"Contrast ×{dialog.Factor:F1} applied.";
}
[RelayCommand]
private void ApplyGrayscale()
{
if (Editor.ActiveDocument is null) return;
_effects.ApplyGrayscale(Editor.ActiveDocument);
Editor.RefreshCanvas();
StatusText = "Grayscale applied.";
}
#endregion
#region Pattern generation (Б4)
[RelayCommand]
private async Task GeneratePatternAsync()
{
if (Editor.Container is null || Owner is not Window window) return;
var dialog = new PatternDialog();
var result = await dialog.ShowDialog<bool?>(window);
if (result != true) return;
try
{
var doc = _patternGen.Generate(
dialog.SelectedPattern,
Editor.Container.Width,
Editor.Container.Height,
[dialog.PatternColor1, dialog.PatternColor2],
dialog.PatternParam1,
dialog.PatternParam2);
Editor.Container.Documents.Add(doc);
Editor.SyncAfterExternalChange();
Editor.SelectDocument(doc);
StatusText = $"Pattern '{dialog.SelectedPattern}' generated.";
}
catch (Exception ex)
{
StatusText = $"Pattern generation failed: {ex.Message}";
}
}
#endregion
#region Export (BMP / GIF)
[RelayCommand]
private async Task ExportBmpAsync()
{
if (Editor.Container is null || Editor.ActiveDocument is null) return;
if (Owner?.StorageProvider is not { } sp) return;
var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export document as BMP",
DefaultExtension = "bmp",
FileTypeChoices = [BmpFileType],
SuggestedFileName = $"{Editor.ActiveDocument.Name}.bmp",
});
if (file is null) return;
var path = file.TryGetLocalPath();
if (path is null) { StatusText = "Error: could not resolve file path."; return; }
try
{
int w = Editor.Container.Width, h = Editor.Container.Height;
uint[] argb = _compositor.Composite(Editor.ActiveDocument, w, h);
await using var fs = File.Create(path);
_bmpExporter.Export(fs, argb, w, h);
StatusText = $"Exported BMP: {Path.GetFileName(path)}";
}
catch (Exception ex)
{
StatusText = $"BMP export failed: {ex.Message}";
}
}
[RelayCommand]
private async Task ExportGifAsync()
{
if (Editor.Container is null || Editor.Container.Documents.Count == 0) return;
if (Owner?.StorageProvider is not { } sp) return;
var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export animation as GIF",
DefaultExtension = "gif",
FileTypeChoices = [GifFileType],
SuggestedFileName = Editor.FilePath is not null
? Path.GetFileNameWithoutExtension(Editor.FilePath) + ".gif"
: "animation.gif",
});
if (file is null) return;
var path = file.TryGetLocalPath();
if (path is null) { StatusText = "Error: could not resolve file path."; return; }
try
{
int w = Editor.Container.Width, h = Editor.Container.Height;
var frames = new List<(uint[] Pixels, uint DelayMs)>();
foreach (var doc in Editor.Container.Documents)
{
uint[] argb = _compositor.Composite(doc, w, h);
frames.Add((argb, doc.FrameDelayMs));
}
await using var fs = File.Create(path);
_gifExporter.Export(fs, frames, w, h);
StatusText = $"Exported GIF ({frames.Count} frames): {Path.GetFileName(path)}";
}
catch (Exception ex)
{
StatusText = $"GIF export failed: {ex.Message}";
}
}
#endregion
}
```
### A.42. `Minint/ViewModels/ToolType.cs`
```csharp
namespace Minint.ViewModels;
public enum ToolType
{
Brush,
Eraser,
Fill,
Select
}
```
### A.43. `Minint/ViewModels/ToolTypeConverters.cs`
```csharp
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace Minint.ViewModels;
/// <summary>
/// Static IValueConverter instances for binding RadioButton.IsChecked to ToolType.
/// These are one-way (read-only) — the RadioButton Command sets the actual value.
/// </summary>
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
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is ToolType t && t == target;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> target;
}
}
```
### A.44. `Minint/ViewModels/ViewModelBase.cs`
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
namespace Minint.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
```
### A.45. `Minint/Views/ContrastDialog.axaml.cs`
```csharp
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace Minint.Views;
public partial class ContrastDialog : Window
{
public double Factor => FactorSlider.Value;
public ContrastDialog()
{
InitializeComponent();
FactorSlider.PropertyChanged += (_, e) =>
{
if (e.Property == Slider.ValueProperty)
FactorLabel.Text = FactorSlider.Value.ToString("F1");
};
OkButton.Click += (_, _) => Close(true);
CancelButton.Click += (_, _) => Close(false);
}
}
```
### A.46. `Minint/Views/MainWindow.axaml.cs`
```csharp
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Minint.Controls;
using Minint.ViewModels;
namespace Minint.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
var canvas = this.FindControl<PixelCanvas>("Canvas");
var hScroll = this.FindControl<ScrollBar>("HScroll");
var vScroll = this.FindControl<ScrollBar>("VScroll");
if (canvas is not null && hScroll is not null && vScroll is not null)
canvas.AttachScrollBars(hScroll, vScroll);
if (canvas is not null && DataContext is MainWindowViewModel vm)
WireCanvasEvents(canvas, vm.Editor);
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel vm)
vm.Owner = this;
}
private static void WireCanvasEvents(PixelCanvas canvas, EditorViewModel editor)
{
canvas.Editor = editor;
canvas.ToolDown += (px, py) => editor.OnToolDown(px, py);
canvas.ToolDrag += (px, py) => editor.OnToolDrag(px, py);
canvas.CursorPixelChanged += pixel => editor.PreviewCenter = pixel;
canvas.GetPreviewMask = () => editor.GetPreviewMask();
canvas.SelectionStart += (px, py) => editor.BeginSelection(px, py);
canvas.SelectionUpdate += (px, py) => editor.UpdateSelection(px, py);
canvas.SelectionEnd += (px, py) => { editor.FinishSelection(px, py); canvas.InvalidateVisual(); };
canvas.PasteMoved += (px, py) => editor.MovePaste(px, py);
canvas.PasteCommitted += () => { editor.CommitPaste(); canvas.InvalidateVisual(); };
canvas.PasteCancelled += () => { editor.CancelPaste(); canvas.InvalidateVisual(); };
}
}
```
### A.47. `Minint/Views/NewContainerDialog.axaml.cs`
```csharp
using Avalonia.Controls;
namespace Minint.Views;
public partial class NewContainerDialog : Window
{
public int CanvasWidth => (int)(WidthInput.Value ?? 64);
public int CanvasHeight => (int)(HeightInput.Value ?? 64);
public NewContainerDialog()
{
InitializeComponent();
OkButton.Click += (_, _) => Close(true);
CancelButton.Click += (_, _) => Close(false);
}
}
```
### A.48. `Minint/Views/PatternDialog.axaml.cs`
```csharp
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Minint.Core.Models;
using Minint.Core.Services;
namespace Minint.Views;
public partial class PatternDialog : Window
{
public PatternType SelectedPattern =>
PatternCombo.SelectedItem is PatternType pt ? pt : PatternType.Checkerboard;
public RgbaColor PatternColor1
{
get
{
var c = Color1Picker.Color;
return new RgbaColor(c.R, c.G, c.B, c.A);
}
}
public RgbaColor PatternColor2
{
get
{
var c = Color2Picker.Color;
return new RgbaColor(c.R, c.G, c.B, c.A);
}
}
public int PatternParam1 => (int)(Param1.Value ?? 8);
public int PatternParam2 => (int)(Param2.Value ?? 8);
public PatternDialog()
{
InitializeComponent();
PatternCombo.ItemsSource = Enum.GetValues<PatternType>().ToList();
PatternCombo.SelectedIndex = 0;
Color1Picker.Color = Color.FromRgb(0, 0, 0);
Color2Picker.Color = Color.FromRgb(255, 255, 255);
OkButton.Click += (_, _) => Close(true);
CancelButton.Click += (_, _) => Close(false);
}
}
```