Compare commits

...

12 Commits

42 changed files with 3980 additions and 120 deletions

View File

@@ -355,11 +355,11 @@ Same caveat about duplicate entries applies.
### 5.9 GIF Export ### 5.9 GIF Export
1. For each document in container, composite into RGBA buffer. 1. For each document in container, composite into RGBA buffer.
2. Use external library (e.g. `SixLabors.ImageSharp`) to encode frames 2. Quantize each frame to 256 colors (popularity-based, with transparent slot).
with per-frame delay from `FrameDelayMs`. 3. LZW-compress and write as GIF89a with per-frame delay from `FrameDelayMs`.
3. Output as animated GIF. 4. NETSCAPE2.0 application extension for infinite looping.
Only the GIF encoder is external; the `.minint` format is fully self-implemented. Fully self-implemented — no external library for GIF encoding.
### 5.10 Pattern Generation (Б4) ### 5.10 Pattern Generation (Б4)
@@ -438,11 +438,11 @@ Canvas (`PixelCanvas` custom control):
**Chosen**: eraser writes index 0 (transparent). **Chosen**: eraser writes index 0 (transparent).
**Rationale**: consistent with palette convention; compositing naturally handles it. **Rationale**: consistent with palette convention; compositing naturally handles it.
### D7. GIF export library ### D7. GIF export
**Chosen**: external library for GIF encoding only (`SixLabors.ImageSharp` or equivalent). **Chosen**: fully self-implemented GIF89a encoder with LZW compression.
**Rationale**: GIF LZW compression is complex and not the focus of this project. **Rationale**: avoids external dependencies; the entire project uses only Avalonia and CommunityToolkit.Mvvm.
**Constraint**: `.minint` serialization remains fully self-implemented. **Details**: popularity-based color quantization to 256 colors, NETSCAPE2.0 looping extension.
### D8. No undo/redo in initial implementation ### D8. No undo/redo in initial implementation
@@ -456,16 +456,16 @@ Canvas (`PixelCanvas` custom control):
| Stage | Scope | | Stage | Scope |
|-------|----------------------------------------------------| |-------|----------------------------------------------------|
| 1 | ✅ Architecture & design (this document) | | 1 | ✅ Architecture & design (this document) |
| 2 | Solution scaffold + domain models | | 2 | Solution scaffold + domain models |
| 3 | Binary `.minint` serialization + round-trip test | | 3 | Binary `.minint` serialization + round-trip test |
| 4 | Compositing + palette management + RGBA buffer | | 4 | Compositing + palette management + RGBA buffer |
| 5 | Basic Avalonia UI (main window, menus, panels) | | 5 | Basic Avalonia UI (main window, menus, panels) |
| 6 | Canvas: pan, zoom, grid, nearest-neighbor | | 6 | Canvas: pan, zoom, grid, nearest-neighbor |
| 7 | Drawing tools: brush, eraser, flood fill, preview | | 7 | Drawing tools: brush, eraser, flood fill, preview |
| 8 | Layer & document management UI | | 8 | Layer & document management UI |
| 9 | Effects: contrast, grayscale, fragment copy, patterns | | 9 | Effects: contrast, grayscale, fragment copy, patterns |
| 10 | Animation playback + BMP/GIF export | | 10 | Animation playback + BMP/GIF export |
| 11 | Polish, tests, documentation | | 11 | Polish, tests, documentation |
--- ---
@@ -477,6 +477,6 @@ Canvas (`PixelCanvas` custom control):
| Avalonia.Desktop 11.3.8 | Minint (UI) | Desktop host | | Avalonia.Desktop 11.3.8 | Minint (UI) | Desktop host |
| Avalonia.Themes.Fluent 11.3.8 | Minint (UI) | Theme | | Avalonia.Themes.Fluent 11.3.8 | Minint (UI) | Theme |
| CommunityToolkit.Mvvm 8.2.1 | Minint (UI) | MVVM helpers | | CommunityToolkit.Mvvm 8.2.1 | Minint (UI) | MVVM helpers |
| SixLabors.ImageSharp (TBD) | Infrastructure | GIF export only | | _(no external deps)_ | Infrastructure | GIF/BMP fully self-implemented |
Core project: **zero** external dependencies. Core project: **zero** external dependencies.

122
FORMAT.md Normal file
View File

@@ -0,0 +1,122 @@
# Спецификация формата `.minint` (версия 1)
## Общие сведения
- Все многобайтовые целые числа — **little-endian**.
- Строки — **UTF-8**, с префиксом длины 1 байт (макс. 255 байт).
- Формат не использует сжатие — данные хранятся как есть.
## Структура файла
```
┌──────────────────────────┐
│ Container Header │ 28 байт (фиксированный)
├──────────────────────────┤
│ Document 1 │ (блок переменной длины)
├──────────────────────────┤
│ Document 2 │
├──────────────────────────┤
│ ... │
├──────────────────────────┤
│ Document N │
└──────────────────────────┘
```
## Container Header (28 байт)
| Смещение | Размер | Тип | Описание |
|----------|--------|--------|---------------------------------|
| 0 | 6 | ASCII | Сигнатура: `MININT` |
| 6 | 2 | uint16 | Версия формата (текущая: `1`) |
| 8 | 4 | uint32 | Ширина (Width) |
| 12 | 4 | uint32 | Высота (Height) |
| 16 | 4 | uint32 | Количество документов |
| 20 | 8 | — | Зарезервировано (нули) |
## Блок документа
Повторяется `DocumentCount` раз, последовательно.
### Заголовок документа
| Размер | Тип | Описание |
|---------------------|--------|-----------------------------|
| 1 | uint8 | Длина имени (NameLen) |
| NameLen | UTF-8 | Имя документа |
| 4 | uint32 | FrameDelayMs |
| 4 | uint32 | Количество цветов (PalCnt) |
### Палитра
`PalCnt × 4` байт. Каждый цвет: `[R, G, B, A]` (по 1 байту).
Индекс 0 всегда соответствует прозрачному цвету `(0, 0, 0, 0)`.
### Ширина индекса
Вычисляется из `PalCnt` (не хранится в файле явно):
| PalCnt | Байт на индекс |
|---------------------|-----------------|
| 1 255 | 1 |
| 256 65 535 | 2 |
| 65 536 16 777 215 | 3 |
| 16 777 216+ | 4 |
### Количество слоёв
| Размер | Тип | Описание |
|--------|--------|-------------------|
| 4 | uint32 | Количество слоёв |
### Блок слоя
Повторяется `LayerCount` раз.
| Размер | Тип | Описание |
|---------------------------|-------|------------------------------------|
| 1 | uint8 | Длина имени слоя (LayerNameLen) |
| LayerNameLen | UTF-8 | Имя слоя |
| 1 | uint8 | Видимость (0 = скрыт, 1 = виден) |
| 1 | uint8 | Opacity (0255) |
| Width × Height × ByteWidth | bytes | Индексы палитры, row-major, LE |
## Правила валидации
1. Сигнатура — строго `MININT` (6 байт ASCII).
2. Версия — строго `1` (неизвестные версии отклоняются).
3. Width, Height >= 1; максимум 65 536.
4. DocumentCount >= 1.
5. PaletteCount >= 1.
6. Каждый индекс пикселя < PaletteCount.
7. IsVisible — только 0 или 1.
8. Зарезервированные байты — допускаются ненулевые (forward compatibility).
9. Неожиданный конец файла — ошибка с описанием контекста.
## Пример
Контейнер 4×4, 1 документ, 2 цвета (прозрачный + красный), 1 слой:
```
4D 49 4E 49 4E 54 — "MININT"
01 00 — version 1
04 00 00 00 — width = 4
04 00 00 00 — height = 4
01 00 00 00 — 1 document
00 00 00 00 00 00 00 00 — reserved
05 — name length = 5
44 6F 63 20 31 — "Doc 1"
64 00 00 00 — FrameDelayMs = 100
02 00 00 00 — palette count = 2
00 00 00 00 — color 0: transparent
FF 00 00 FF — color 1: red (R=255, G=0, B=0, A=255)
01 00 00 00 — 1 layer
07 — layer name length = 7
4C 61 79 65 72 20 31 — "Layer 1"
01 — visible
FF — opacity = 255
00 00 00 00 00 00 00 00 — 16 pixels, all index 0 (transparent)
00 00 00 00 00 00 00 00
```

View File

@@ -21,6 +21,13 @@ public sealed class MinintDocument
public List<MinintLayer> Layers { 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) public MinintDocument(string name)
{ {
Name = name; Name = name;
@@ -50,4 +57,48 @@ public sealed class MinintDocument
<= 16_777_215 => 3, <= 16_777_215 => 3,
_ => 4 _ => 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.TryGetValue(color, out int idx) ? idx : -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;
}
} }

View File

@@ -1,19 +1,39 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Minint.Core.Models; namespace Minint.Core.Models;
/// <summary> /// <summary>
/// A single raster layer. Pixels are indices into the parent document's palette. /// A single raster layer. Pixels are indices into the parent document's palette.
/// Array layout is row-major: Pixels[y * width + x]. /// Array layout is row-major: Pixels[y * width + x].
/// </summary> /// </summary>
public sealed class MinintLayer public sealed class MinintLayer : INotifyPropertyChanged
{ {
public string Name { get; set; } private string _name;
public bool IsVisible { get; set; } 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> /// <summary>
/// Per-layer opacity (0 = fully transparent, 255 = fully opaque). /// Per-layer opacity (0 = fully transparent, 255 = fully opaque).
/// Used during compositing: effective alpha = paletteColor.A * Opacity / 255. /// Used during compositing: effective alpha = paletteColor.A * Opacity / 255.
/// </summary> /// </summary>
public byte Opacity { get; set; } public byte Opacity
{
get => _opacity;
set { if (_opacity != value) { _opacity = value; Notify(); } }
}
/// <summary> /// <summary>
/// Palette indices, length must equal container Width * Height. /// Palette indices, length must equal container Width * Height.
@@ -23,9 +43,9 @@ public sealed class MinintLayer
public MinintLayer(string name, int pixelCount) public MinintLayer(string name, int pixelCount)
{ {
Name = name; _name = name;
IsVisible = true; _isVisible = true;
Opacity = 255; _opacity = 255;
Pixels = new int[pixelCount]; Pixels = new int[pixelCount];
} }
@@ -34,9 +54,13 @@ public sealed class MinintLayer
/// </summary> /// </summary>
public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels) public MinintLayer(string name, bool isVisible, byte opacity, int[] pixels)
{ {
Name = name; _name = name;
IsVisible = isVisible; _isVisible = isVisible;
Opacity = opacity; _opacity = opacity;
Pixels = pixels; Pixels = pixels;
} }
public event PropertyChangedEventHandler? PropertyChanged;
private void Notify([CallerMemberName] string? prop = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
} }

View File

@@ -0,0 +1,18 @@
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);
}

View File

@@ -0,0 +1,43 @@
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;
}
}

View File

@@ -0,0 +1,47 @@
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));
}
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using Minint.Core.Models;
namespace Minint.Core.Services.Impl;
public sealed class FragmentService : IFragmentService
{
public void CopyFragment(
MinintDocument srcDoc, int srcLayerIndex,
int srcX, int srcY, int regionWidth, int regionHeight,
MinintDocument dstDoc, int dstLayerIndex,
int dstX, int dstY,
int containerWidth, int containerHeight)
{
ArgumentOutOfRangeException.ThrowIfNegative(srcLayerIndex);
ArgumentOutOfRangeException.ThrowIfNegative(dstLayerIndex);
if (srcLayerIndex >= srcDoc.Layers.Count)
throw new ArgumentOutOfRangeException(nameof(srcLayerIndex));
if (dstLayerIndex >= dstDoc.Layers.Count)
throw new ArgumentOutOfRangeException(nameof(dstLayerIndex));
var srcLayer = srcDoc.Layers[srcLayerIndex];
var dstLayer = dstDoc.Layers[dstLayerIndex];
int clippedSrcX = Math.Max(srcX, 0);
int clippedSrcY = Math.Max(srcY, 0);
int clippedEndX = Math.Min(srcX + regionWidth, containerWidth);
int clippedEndY = Math.Min(srcY + regionHeight, containerHeight);
for (int sy = clippedSrcY; sy < clippedEndY; sy++)
{
int dy = dstY + (sy - srcY);
if (dy < 0 || dy >= containerHeight) continue;
for (int sx = clippedSrcX; sx < clippedEndX; sx++)
{
int dx = dstX + (sx - srcX);
if (dx < 0 || dx >= containerWidth) continue;
int srcIdx = srcLayer.Pixels[sy * containerWidth + sx];
if (srcIdx == 0) continue; // skip transparent
RgbaColor color = srcDoc.Palette[srcIdx];
int dstIdx = dstDoc.EnsureColorCached(color);
dstLayer.Pixels[dy * containerWidth + dx] = dstIdx;
}
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
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);
}
}

View File

@@ -5,26 +5,10 @@ namespace Minint.Core.Services.Impl;
public sealed class PaletteService : IPaletteService public sealed class PaletteService : IPaletteService
{ {
public int FindColor(MinintDocument document, RgbaColor color) public int FindColor(MinintDocument document, RgbaColor color)
{ => document.FindColorCached(color);
var palette = document.Palette;
for (int i = 0; i < palette.Count; i++)
{
if (palette[i] == color)
return i;
}
return -1;
}
public int EnsureColor(MinintDocument document, RgbaColor color) public int EnsureColor(MinintDocument document, RgbaColor color)
{ => document.EnsureColorCached(color);
int idx = FindColor(document, color);
if (idx >= 0)
return idx;
idx = document.Palette.Count;
document.Palette.Add(color);
return idx;
}
public void CompactPalette(MinintDocument document) public void CompactPalette(MinintDocument document)
{ {
@@ -32,19 +16,16 @@ public sealed class PaletteService : IPaletteService
if (palette.Count <= 1) if (palette.Count <= 1)
return; return;
// 1. Collect indices actually used across all layers var usedIndices = new HashSet<int> { 0 };
var usedIndices = new HashSet<int> { 0 }; // always keep transparent
foreach (var layer in document.Layers) foreach (var layer in document.Layers)
{ {
foreach (int idx in layer.Pixels) foreach (int idx in layer.Pixels)
usedIndices.Add(idx); usedIndices.Add(idx);
} }
// 2. Build new palette and old→new mapping
var oldToNew = new int[palette.Count]; var oldToNew = new int[palette.Count];
var newPalette = new List<RgbaColor>(usedIndices.Count); var newPalette = new List<RgbaColor>(usedIndices.Count);
// Index 0 (transparent) stays at 0
newPalette.Add(palette[0]); newPalette.Add(palette[0]);
oldToNew[0] = 0; oldToNew[0] = 0;
@@ -55,23 +36,21 @@ public sealed class PaletteService : IPaletteService
oldToNew[i] = newPalette.Count; oldToNew[i] = newPalette.Count;
newPalette.Add(palette[i]); newPalette.Add(palette[i]);
} }
// unused indices don't get a mapping — they'll never be looked up
} }
// 3. If nothing was removed, skip the remap
if (newPalette.Count == palette.Count) if (newPalette.Count == palette.Count)
return; return;
// 4. Replace palette
palette.Clear(); palette.Clear();
palette.AddRange(newPalette); palette.AddRange(newPalette);
// 5. Remap all pixel arrays
foreach (var layer in document.Layers) foreach (var layer in document.Layers)
{ {
var px = layer.Pixels; var px = layer.Pixels;
for (int i = 0; i < px.Length; i++) for (int i = 0; i < px.Length; i++)
px[i] = oldToNew[px[i]]; px[i] = oldToNew[px[i]];
} }
document.InvalidatePaletteCache();
} }
} }

View File

@@ -0,0 +1,113 @@
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);
}

View File

@@ -0,0 +1,80 @@
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);
}
}
}
}

View File

@@ -0,0 +1,329 @@
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
}

View File

@@ -0,0 +1,76 @@
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]);
}
}

View File

@@ -0,0 +1,63 @@
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);
});
}
}

View File

@@ -0,0 +1,68 @@
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);
});
}
}

View File

@@ -0,0 +1,52 @@
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]);
}
}

View File

@@ -0,0 +1,68 @@
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
}
}

View File

@@ -0,0 +1,65 @@
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);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Minint.Core\Minint.Core.csproj" />
<ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,41 @@
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);
}
}

View File

@@ -0,0 +1,121 @@
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);
}
}

View File

@@ -1,5 +1,6 @@
<Solution> <Solution>
<Project Path="Minint.Core/Minint.Core.csproj" /> <Project Path="Minint.Core/Minint.Core.csproj" />
<Project Path="Minint.Infrastructure/Minint.Infrastructure.csproj" /> <Project Path="Minint.Infrastructure/Minint.Infrastructure.csproj" />
<Project Path="Minint.Tests/Minint.Tests.csproj" />
<Project Path="Minint/Minint.csproj" /> <Project Path="Minint/Minint.csproj" />
</Solution> </Solution>

View File

@@ -11,5 +11,6 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml"/>
</Application.Styles> </Application.Styles>
</Application> </Application>

View File

@@ -0,0 +1,129 @@
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;
}
}

View File

@@ -0,0 +1,575 @@
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;
}
}
}
}

119
Minint/Controls/Viewport.cs Normal file
View File

@@ -0,0 +1,119 @@
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);
}
}

View File

@@ -1,31 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationManifest>app.manifest</ApplicationManifest> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" /> <ItemGroup>
</ItemGroup> <AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Minint.Core\Minint.Core.csproj" /> <ItemGroup>
<ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" /> <ProjectReference Include="..\Minint.Core\Minint.Core.csproj" />
</ItemGroup> <ProjectReference Include="..\Minint.Infrastructure\Minint.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.8" /> <ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" /> <PackageReference Include="Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8"> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
</PackageReference> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" /> </PackageReference>
</ItemGroup> <PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.8" />
</Project> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
</ItemGroup>
</Project>

View File

@@ -28,7 +28,8 @@ sealed class Program
=> AppBuilder.Configure<App>() => AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.WithInterFont() .WithInterFont()
.LogToTrace(); .LogToTrace()
.With(new X11PlatformOptions { OverlayPopups = true });
// TODO: temporary tests — remove after verification stages. // TODO: temporary tests — remove after verification stages.

View File

@@ -0,0 +1,736 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Minint.Core.Models;
using Minint.Core.Services;
using Minint.Core.Services.Impl;
namespace Minint.ViewModels;
/// <summary>
/// Palette-independent clipboard fragment: stores resolved RGBA pixels.
/// </summary>
public sealed record ClipboardFragment(int Width, int Height, RgbaColor[] Pixels);
public partial class EditorViewModel : ViewModelBase
{
private readonly ICompositor _compositor = new Compositor();
private readonly IPaletteService _paletteService = new PaletteService();
private readonly IDrawingService _drawingService = new DrawingService();
private readonly IFloodFillService _floodFillService = new FloodFillService();
private readonly IFragmentService _fragmentService = new FragmentService();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasContainer))]
[NotifyPropertyChangedFor(nameof(Title))]
private MinintContainer? _container;
[ObservableProperty]
private MinintDocument? _activeDocument;
[ObservableProperty]
private MinintLayer? _activeLayer;
private bool _suppressDocumentSync;
[ObservableProperty]
private WriteableBitmap? _canvasBitmap;
[ObservableProperty]
private bool _showGrid;
// Tool state
[ObservableProperty]
private ToolType _activeTool = ToolType.Brush;
[ObservableProperty]
private int _brushRadius = 1;
[ObservableProperty]
private (int X, int Y)? _previewCenter;
private Avalonia.Media.Color _previewColor = Avalonia.Media.Color.FromArgb(255, 0, 0, 0);
public Avalonia.Media.Color PreviewColor
{
get => _previewColor;
set
{
if (_previewColor == value) return;
_previewColor = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedColor));
}
}
public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
// Selection state (Select tool rubber-band)
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelection))]
private (int X, int Y, int W, int H)? _selectionRect;
public bool HasSelection => SelectionRect is not null;
// Clipboard
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasClipboard))]
private ClipboardFragment? _clipboard;
public bool HasClipboard => Clipboard is not null;
// Paste mode
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPasting))]
private (int X, int Y)? _pastePosition;
public bool IsPasting => PastePosition is not null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Title))]
private string? _filePath;
public bool HasContainer => Container is not null;
public string Title => FilePath is not null
? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
: Container is not null
? "Minint — Untitled"
: "Minint";
public ObservableCollection<MinintDocument> Documents { get; } = [];
public ObservableCollection<MinintLayer> Layers { get; } = [];
#region Container / Document management
public void NewContainer(int width, int height)
{
var c = new MinintContainer(width, height);
c.AddNewDocument("Document 1");
LoadContainer(c, null);
}
public void LoadContainer(MinintContainer container, string? path)
{
Container = container;
FilePath = path;
SyncDocumentsList();
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
}
partial void OnActiveDocumentChanged(MinintDocument? value)
{
if (_suppressDocumentSync) return;
SyncLayersAndCanvas(value);
}
public void SelectDocument(MinintDocument? doc)
{
_suppressDocumentSync = true;
ActiveDocument = doc;
_suppressDocumentSync = false;
SyncLayersAndCanvas(doc);
}
public void SyncAfterExternalChange() => SyncDocumentsList();
private void SyncDocumentsList()
{
Documents.Clear();
if (Container is null) return;
foreach (var doc in Container.Documents)
Documents.Add(doc);
}
private void SyncLayersAndCanvas(MinintDocument? doc)
{
UnsubscribeLayerVisibility();
Layers.Clear();
if (doc is not null)
{
foreach (var layer in doc.Layers)
Layers.Add(layer);
ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
}
else
{
ActiveLayer = null;
}
SubscribeLayerVisibility();
RefreshCanvas();
}
#endregion
#region Layer visibility change tracking
private void SubscribeLayerVisibility()
{
foreach (var layer in Layers)
{
if (layer is INotifyPropertyChanged npc)
npc.PropertyChanged += OnLayerPropertyChanged;
}
}
private void UnsubscribeLayerVisibility()
{
foreach (var layer in Layers)
{
if (layer is INotifyPropertyChanged npc)
npc.PropertyChanged -= OnLayerPropertyChanged;
}
}
private void OnLayerPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MinintLayer.IsVisible) or nameof(MinintLayer.Opacity))
RefreshCanvas();
}
#endregion
#region Document commands
[RelayCommand]
private void AddDocument()
{
if (Container is null) return;
int num = Container.Documents.Count + 1;
var doc = Container.AddNewDocument($"Document {num}");
Documents.Add(doc);
SelectDocument(doc);
}
[RelayCommand]
private void RemoveDocument()
{
if (Container is null || ActiveDocument is null) return;
if (Container.Documents.Count <= 1) return;
var doc = ActiveDocument;
int idx = Container.Documents.IndexOf(doc);
Container.Documents.Remove(doc);
Documents.Remove(doc);
int newIdx = Math.Min(idx, Container.Documents.Count - 1);
SelectDocument(newIdx >= 0 ? Container.Documents[newIdx] : null);
}
[RelayCommand]
private void RenameDocument() { }
[RelayCommand]
private void MoveDocumentUp()
{
if (Container is null || ActiveDocument is null) return;
int idx = Container.Documents.IndexOf(ActiveDocument);
if (idx <= 0) return;
(Container.Documents[idx], Container.Documents[idx - 1]) = (Container.Documents[idx - 1], Container.Documents[idx]);
SyncDocumentsList();
_suppressDocumentSync = true;
ActiveDocument = Container.Documents[idx - 1];
_suppressDocumentSync = false;
}
[RelayCommand]
private void MoveDocumentDown()
{
if (Container is null || ActiveDocument is null) return;
int idx = Container.Documents.IndexOf(ActiveDocument);
if (idx < 0 || idx >= Container.Documents.Count - 1) return;
(Container.Documents[idx], Container.Documents[idx + 1]) = (Container.Documents[idx + 1], Container.Documents[idx]);
SyncDocumentsList();
_suppressDocumentSync = true;
ActiveDocument = Container.Documents[idx + 1];
_suppressDocumentSync = false;
}
#endregion
#region Layer commands
[RelayCommand]
private void AddLayer()
{
if (Container is null || ActiveDocument is null) return;
int num = ActiveDocument.Layers.Count + 1;
var layer = new MinintLayer($"Layer {num}", Container.PixelCount);
ActiveDocument.Layers.Add(layer);
Layers.Add(layer);
ActiveLayer = layer;
SubscribeLayerVisibility();
}
[RelayCommand]
private void RemoveLayer()
{
if (ActiveDocument is null || ActiveLayer is null) return;
if (ActiveDocument.Layers.Count <= 1) return;
UnsubscribeLayerVisibility();
var layer = ActiveLayer;
int idx = ActiveDocument.Layers.IndexOf(layer);
ActiveDocument.Layers.Remove(layer);
Layers.Remove(layer);
int newIdx = Math.Min(idx, ActiveDocument.Layers.Count - 1);
ActiveLayer = newIdx >= 0 ? ActiveDocument.Layers[newIdx] : null;
SubscribeLayerVisibility();
RefreshCanvas();
}
[RelayCommand]
private void MoveLayerUp()
{
if (ActiveDocument is null || ActiveLayer is null) return;
int idx = ActiveDocument.Layers.IndexOf(ActiveLayer);
if (idx <= 0) return;
UnsubscribeLayerVisibility();
var layer = ActiveLayer;
(ActiveDocument.Layers[idx], ActiveDocument.Layers[idx - 1]) = (ActiveDocument.Layers[idx - 1], ActiveDocument.Layers[idx]);
Layers.Move(idx, idx - 1);
ActiveLayer = layer;
SubscribeLayerVisibility();
RefreshCanvas();
}
[RelayCommand]
private void MoveLayerDown()
{
if (ActiveDocument is null || ActiveLayer is null) return;
int idx = ActiveDocument.Layers.IndexOf(ActiveLayer);
if (idx < 0 || idx >= ActiveDocument.Layers.Count - 1) return;
UnsubscribeLayerVisibility();
var layer = ActiveLayer;
(ActiveDocument.Layers[idx], ActiveDocument.Layers[idx + 1]) = (ActiveDocument.Layers[idx + 1], ActiveDocument.Layers[idx]);
Layers.Move(idx, idx + 1);
ActiveLayer = layer;
SubscribeLayerVisibility();
RefreshCanvas();
}
[RelayCommand]
private void DuplicateLayer()
{
if (Container is null || ActiveDocument is null || ActiveLayer is null) return;
var src = ActiveLayer;
var dup = new MinintLayer(src.Name + " copy", src.IsVisible, src.Opacity, (int[])src.Pixels.Clone());
int idx = ActiveDocument.Layers.IndexOf(src) + 1;
ActiveDocument.Layers.Insert(idx, dup);
UnsubscribeLayerVisibility();
Layers.Insert(idx, dup);
ActiveLayer = dup;
SubscribeLayerVisibility();
RefreshCanvas();
}
#endregion
#region Drawing
public void OnToolDown(int px, int py)
{
if (IsPlaying) return;
if (Container is null || ActiveDocument is null || ActiveLayer is null)
return;
int w = Container.Width, h = Container.Height;
if (px < 0 || px >= w || py < 0 || py >= h)
return;
if (ActiveTool == ToolType.Select) return; // handled separately
switch (ActiveTool)
{
case ToolType.Brush:
{
int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
_drawingService.ApplyBrush(ActiveLayer, px, py, BrushRadius, colorIdx, w, h);
break;
}
case ToolType.Eraser:
_drawingService.ApplyEraser(ActiveLayer, px, py, BrushRadius, w, h);
break;
case ToolType.Fill:
{
int colorIdx = ActiveDocument.EnsureColorCached(SelectedColor);
_floodFillService.Fill(ActiveLayer, px, py, colorIdx, w, h);
break;
}
}
RefreshCanvas();
}
public void OnToolDrag(int px, int py)
{
if (IsPlaying) return;
if (ActiveTool is ToolType.Fill or ToolType.Select) return;
OnToolDown(px, py);
}
public List<(int X, int Y)>? GetPreviewMask()
{
if (PreviewCenter is null || Container is null)
return null;
if (ActiveTool is ToolType.Fill or ToolType.Select)
return null;
var (cx, cy) = PreviewCenter.Value;
return _drawingService.GetBrushMask(cx, cy, BrushRadius, Container.Width, Container.Height);
}
[RelayCommand]
private void SelectBrush() { CancelPasteMode(); ActiveTool = ToolType.Brush; }
[RelayCommand]
private void SelectEraser() { CancelPasteMode(); ActiveTool = ToolType.Eraser; }
[RelayCommand]
private void SelectFill() { CancelPasteMode(); ActiveTool = ToolType.Fill; }
[RelayCommand]
private void SelectSelectTool() { CancelPasteMode(); ActiveTool = ToolType.Select; }
#endregion
#region Selection + Copy/Paste (A4)
/// <summary>Called by PixelCanvas when selection drag starts.</summary>
public void BeginSelection(int px, int py)
{
if (IsPlaying) return;
SelectionRect = (px, py, 0, 0);
}
/// <summary>Called by PixelCanvas as the user drags.</summary>
public void UpdateSelection(int px, int py)
{
if (SelectionRect is null) return;
var s = SelectionRect.Value;
int x = Math.Min(s.X, px);
int y = Math.Min(s.Y, py);
int w = Math.Abs(px - s.X) + 1;
int h = Math.Abs(py - s.Y) + 1;
// Store normalized rect but keep original anchor in _selAnchor
_selectionRectNormalized = (x, y, w, h);
}
/// <summary>Called by PixelCanvas when mouse is released.</summary>
public void FinishSelection(int px, int py)
{
if (SelectionRect is null) return;
var s = SelectionRect.Value;
int x0 = Math.Min(s.X, px);
int y0 = Math.Min(s.Y, py);
int rw = Math.Abs(px - s.X) + 1;
int rh = Math.Abs(py - s.Y) + 1;
if (Container is not null)
{
x0 = Math.Max(0, x0);
y0 = Math.Max(0, y0);
rw = Math.Min(rw, Container.Width - x0);
rh = Math.Min(rh, Container.Height - y0);
}
if (rw <= 0 || rh <= 0)
{
SelectionRect = null;
_selectionRectNormalized = null;
return;
}
SelectionRect = (x0, y0, rw, rh);
_selectionRectNormalized = SelectionRect;
}
private (int X, int Y, int W, int H)? _selectionRectNormalized;
/// <summary>The normalized (positive W/H, clamped) selection rectangle for rendering.</summary>
public (int X, int Y, int W, int H)? SelectionRectNormalized => _selectionRectNormalized;
[RelayCommand]
private void CopySelection()
{
if (SelectionRect is null || ActiveDocument is null || ActiveLayer is null || Container is null)
return;
var (sx, sy, sw, sh) = SelectionRect.Value;
int cw = Container.Width;
var palette = ActiveDocument.Palette;
var srcPixels = ActiveLayer.Pixels;
var buf = new RgbaColor[sw * sh];
for (int dy = 0; dy < sh; dy++)
{
int srcRow = sy + dy;
for (int dx = 0; dx < sw; dx++)
{
int srcCol = sx + dx;
int idx = srcPixels[srcRow * cw + srcCol];
buf[dy * sw + dx] = idx < palette.Count ? palette[idx] : RgbaColor.Transparent;
}
}
Clipboard = new ClipboardFragment(sw, sh, buf);
}
[RelayCommand]
private void PasteClipboard()
{
if (Clipboard is null) return;
PastePosition = (0, 0);
}
public void MovePaste(int px, int py)
{
if (IsPlaying || !IsPasting) return;
PastePosition = (px, py);
}
[RelayCommand]
public void CommitPaste()
{
if (IsPlaying) return;
if (!IsPasting || Clipboard is null || ActiveDocument is null || ActiveLayer is null || Container is null)
return;
var (px, py) = PastePosition!.Value;
int cw = Container.Width, ch = Container.Height;
var frag = Clipboard;
var dstPixels = ActiveLayer.Pixels;
for (int fy = 0; fy < frag.Height; fy++)
{
int dy = py + fy;
if (dy < 0 || dy >= ch) continue;
for (int fx = 0; fx < frag.Width; fx++)
{
int dx = px + fx;
if (dx < 0 || dx >= cw) continue;
var color = frag.Pixels[fy * frag.Width + fx];
if (color.A == 0) continue; // skip transparent
int colorIdx = ActiveDocument.EnsureColorCached(color);
dstPixels[dy * cw + dx] = colorIdx;
}
}
PastePosition = null;
SelectionRect = null;
_selectionRectNormalized = null;
RefreshCanvas();
}
[RelayCommand]
public void CancelPaste()
{
PastePosition = null;
}
public void ClearSelection()
{
SelectionRect = null;
_selectionRectNormalized = null;
}
private void CancelPasteMode()
{
PastePosition = null;
SelectionRect = null;
_selectionRectNormalized = null;
}
#endregion
#region Canvas rendering
public void RefreshCanvas()
{
if (Container is null || ActiveDocument is null)
{
CanvasBitmap = null;
return;
}
int w = Container.Width;
int h = Container.Height;
uint[] argb = _compositor.Composite(ActiveDocument, w, h);
var bmp = new WriteableBitmap(
new PixelSize(w, h),
new Vector(96, 96),
Avalonia.Platform.PixelFormat.Bgra8888);
using (var fb = bmp.Lock())
{
unsafe
{
var dst = new Span<uint>((void*)fb.Address, w * h);
for (int i = 0; i < argb.Length; i++)
{
uint px = argb[i];
byte a = (byte)(px >> 24);
byte r = (byte)((px >> 16) & 0xFF);
byte g = (byte)((px >> 8) & 0xFF);
byte b = (byte)(px & 0xFF);
if (a == 255)
{
dst[i] = px;
}
else if (a == 0)
{
dst[i] = 0;
}
else
{
r = (byte)(r * a / 255);
g = (byte)(g * a / 255);
b = (byte)(b * a / 255);
dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
}
}
}
}
CanvasBitmap = bmp;
}
#endregion
#region Animation playback
private DispatcherTimer? _animationTimer;
private int _animationFrameIndex;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNotPlaying))]
private bool _isPlaying;
public bool IsNotPlaying => !IsPlaying;
[RelayCommand]
private void PlayAnimation()
{
if (Container is null || Container.Documents.Count < 2) return;
if (IsPlaying) return;
IsPlaying = true;
_animationFrameIndex = ActiveDocument is not null
? Container.Documents.IndexOf(ActiveDocument)
: 0;
if (_animationFrameIndex < 0) _animationFrameIndex = 0;
AdvanceAnimationFrame();
}
[RelayCommand]
private void StopAnimation()
{
_animationTimer?.Stop();
_animationTimer = null;
IsPlaying = false;
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;
}

View File

@@ -1,6 +1,287 @@
namespace Minint.ViewModels; 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.Models;
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 public partial class MainWindowViewModel : ViewModelBase
{ {
public string Greeting { get; } = "Welcome to Avalonia!"; 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
} }

View File

@@ -0,0 +1,9 @@
namespace Minint.ViewModels;
public enum ToolType
{
Brush,
Eraser,
Fill,
Select
}

View File

@@ -0,0 +1,26 @@
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;
}
}

View File

@@ -0,0 +1,20 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Minint.Views.ContrastDialog"
Title="Adjust Contrast"
Width="320"
WindowStartupLocation="CenterOwner"
CanResize="False"
SizeToContent="Height">
<StackPanel Margin="16" Spacing="10">
<TextBlock Text="Contrast factor (0 = gray, 1 = no change, >1 = more contrast):"/>
<Slider x:Name="FactorSlider" Minimum="0" Maximum="3" Value="1"
TickFrequency="0.1" IsSnapToTickEnabled="True"/>
<TextBlock x:Name="FactorLabel" Text="1.0" HorizontalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,8,0,0">
<Button Content="Apply" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,24 @@
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);
}
}

View File

@@ -1,20 +1,209 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Minint.ViewModels" xmlns:vm="using:Minint.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:controls="using:Minint.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Class="Minint.Views.MainWindow" mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="700"
x:DataType="vm:MainWindowViewModel" x:Class="Minint.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico" x:DataType="vm:MainWindowViewModel"
Title="Minint"> Icon="/Assets/avalonia-logo.ico"
Title="{Binding Editor.Title}"
<Design.DataContext> Width="1024" Height="700"
<!-- This only sets the DataContext for the previewer in an IDE, ToolTip.ShowDelay="400">
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/> <Design.DataContext>
</Design.DataContext> <vm:MainWindowViewModel/>
</Design.DataContext>
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<DockPanel>
</Window> <!-- Menu bar -->
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
<Separator/>
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
<Separator/>
<MenuItem Header="Export Document as _BMP…" Command="{Binding ExportBmpCommand}"
ToolTip.Tip="Export the active document as a 32-bit BMP file"/>
<MenuItem Header="Export All as _GIF…" Command="{Binding ExportGifCommand}"
ToolTip.Tip="Export all documents as an animated GIF"/>
</MenuItem>
<MenuItem Header="_Edit">
<MenuItem Header="_Copy" Command="{Binding Editor.CopySelectionCommand}"
HotKey="Ctrl+C"
ToolTip.Tip="Copy the selected region to clipboard"/>
<MenuItem Header="_Paste" Command="{Binding Editor.PasteClipboardCommand}"
HotKey="Ctrl+V"
ToolTip.Tip="Paste clipboard fragment onto the canvas"/>
</MenuItem>
<MenuItem Header="_Image">
<MenuItem Header="Adjust _Contrast…" Command="{Binding ApplyContrastCommand}"
ToolTip.Tip="Adjust contrast of the active document's palette"/>
<MenuItem Header="Convert to _Grayscale" Command="{Binding ApplyGrayscaleCommand}"
ToolTip.Tip="Convert active document to grayscale"/>
<Separator/>
<MenuItem Header="Generate _Pattern…" Command="{Binding GeneratePatternCommand}"
ToolTip.Tip="Generate a new document with a parametric pattern"/>
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
IsChecked="{Binding Editor.ShowGrid, Mode=TwoWay}" HotKey="Ctrl+G"/>
</MenuItem>
</Menu>
<!-- Toolbar -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="0,0,0,1" Padding="6,4">
<StackPanel Orientation="Horizontal" Spacing="10">
<RadioButton GroupName="Tool" Content="Brush"
ToolTip.Tip="Brush tool — draw with selected color"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsBrush}}"
Command="{Binding Editor.SelectBrushCommand}"/>
<RadioButton GroupName="Tool" Content="Eraser"
ToolTip.Tip="Eraser tool — erase to transparent"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsEraser}}"
Command="{Binding Editor.SelectEraserCommand}"/>
<RadioButton GroupName="Tool" Content="Fill"
ToolTip.Tip="Fill tool — flood fill with selected color"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
Command="{Binding Editor.SelectFillCommand}"/>
<RadioButton GroupName="Tool" Content="Select"
ToolTip.Tip="Select tool — drag to select a region, then Copy/Paste"
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsSelect}}"
Command="{Binding Editor.SelectSelectToolCommand}"/>
<Separator/>
<TextBlock Text="Size:" VerticalAlignment="Center"/>
<Slider Value="{Binding Editor.BrushRadius}" Minimum="0" Maximum="64"
TickFrequency="1" IsSnapToTickEnabled="True" Width="120"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Editor.BrushRadius}" VerticalAlignment="Center"
Width="20" TextAlignment="Center"/>
<Separator/>
<TextBlock Text="Color:" VerticalAlignment="Center"/>
<ColorPicker x:Name="ToolColorPicker"
Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
IsAlphaVisible="True"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Status bar -->
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
Padding="8,2">
<TextBlock Text="{Binding StatusText}" FontSize="12"/>
</Border>
<!-- Main content: left panel, canvas, right panel -->
<Grid ColumnDefinitions="200,*,200">
<!-- Left panel: documents -->
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="0,0,1,0" Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
<!-- Animation controls -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="4" Margin="0,4,0,0">
<Button Content="▶" ToolTip.Tip="Play animation"
Command="{Binding Editor.PlayAnimationCommand}"
IsEnabled="{Binding Editor.IsNotPlaying}" Padding="6,2"/>
<Button Content="■" ToolTip.Tip="Stop animation"
Command="{Binding Editor.StopAnimationCommand}"
IsEnabled="{Binding Editor.IsPlaying}" Padding="6,2"/>
</StackPanel>
<!-- Frame delay -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="4" Margin="0,4,0,0">
<TextBlock Text="Delay:" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Value="{Binding Editor.ActiveDocument.FrameDelayMs}"
Minimum="10" Maximum="10000" Increment="10"
FormatString="0"
Width="110" FontSize="12"
ToolTip.Tip="Frame delay in milliseconds"
VerticalAlignment="Center"
Foreground="{DynamicResource SystemControlForegroundBaseHighBrush}"/>
<TextBlock Text="ms" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
<!-- Document management buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="2" Margin="0,4,0,0">
<Button Content="+" ToolTip.Tip="Add a new document (frame)"
Command="{Binding Editor.AddDocumentCommand}" Padding="6,2"/>
<Button Content="" ToolTip.Tip="Remove selected document"
Command="{Binding Editor.RemoveDocumentCommand}" Padding="6,2"/>
<Button Content="▲" ToolTip.Tip="Move document up in the list"
Command="{Binding Editor.MoveDocumentUpCommand}" Padding="6,2"/>
<Button Content="▼" ToolTip.Tip="Move document down in the list"
Command="{Binding Editor.MoveDocumentDownCommand}" Padding="6,2"/>
</StackPanel>
<ListBox ItemsSource="{Binding Editor.Documents}"
SelectedItem="{Binding Editor.ActiveDocument}"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<controls:EditableTextBlock Text="{Binding Name}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Center: canvas with scrollbars -->
<Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto">
<Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True">
<controls:PixelCanvas x:Name="Canvas"
SourceBitmap="{Binding Editor.CanvasBitmap}"
ShowGrid="{Binding Editor.ShowGrid}"/>
</Border>
<ScrollBar x:Name="HScroll" Grid.Row="1" Grid.Column="0"
Orientation="Horizontal"/>
<ScrollBar x:Name="VScroll" Grid.Row="0" Grid.Column="1"
Orientation="Vertical"/>
</Grid>
<!-- Right panel: layers -->
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1,0,0,0" Padding="4">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="2" Margin="0,4,0,0">
<Button Content="+" ToolTip.Tip="Add a new empty layer"
Command="{Binding Editor.AddLayerCommand}" Padding="6,2"/>
<Button Content="" ToolTip.Tip="Remove selected layer"
Command="{Binding Editor.RemoveLayerCommand}" Padding="6,2"/>
<Button Content="▲" ToolTip.Tip="Move layer up (draw later, appears on top)"
Command="{Binding Editor.MoveLayerUpCommand}" Padding="6,2"/>
<Button Content="▼" ToolTip.Tip="Move layer down (draw earlier, appears below)"
Command="{Binding Editor.MoveLayerDownCommand}" Padding="6,2"/>
<Button Content="⧉" ToolTip.Tip="Duplicate selected layer with all pixels"
Command="{Binding Editor.DuplicateLayerCommand}" Padding="6,2"/>
</StackPanel>
<ListBox ItemsSource="{Binding Editor.Layers}"
SelectedItem="{Binding Editor.ActiveLayer}"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Spacing="4">
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"
VerticalAlignment="Center"
ToolTip.Tip="Toggle layer visibility"/>
<controls:EditableTextBlock Text="{Binding Name}"
VerticalAlignment="Center" MinWidth="60"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</Window>

View File

@@ -1,11 +1,55 @@
using Avalonia.Controls; using System;
using Avalonia.Controls;
namespace Minint.Views; using Avalonia.Controls.Primitives;
using Minint.Controls;
public partial class MainWindow : Window using Minint.ViewModels;
{
public MainWindow() namespace Minint.Views;
{
InitializeComponent(); 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(); };
}
}

View File

@@ -0,0 +1,31 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Minint.Views.NewContainerDialog"
Title="New Container"
Width="300"
WindowStartupLocation="CenterOwner"
CanResize="False"
SizeToContent="Height">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Canvas size:" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" RowSpacing="8" ColumnSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Width:" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="0" Grid.Column="1"
x:Name="WidthInput"
Value="64" Minimum="1" Maximum="4096" Increment="1"
FormatString="0"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Height:" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="1" Grid.Column="1"
x:Name="HeightInput"
Value="64" Minimum="1" Maximum="4096" Increment="1"
FormatString="0"/>
</Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,8,0,0">
<Button Content="Create" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,17 @@
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);
}
}

View File

@@ -0,0 +1,38 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Minint.Views.PatternDialog"
Title="Generate Pattern"
Width="640"
Height="520"
MinWidth="480"
MinHeight="400"
WindowStartupLocation="CenterOwner"
CanResize="True">
<StackPanel Margin="16" Spacing="8">
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="6" ColumnSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Pattern:" VerticalAlignment="Center"/>
<ComboBox Grid.Row="0" Grid.Column="1" x:Name="PatternCombo" HorizontalAlignment="Stretch"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Color 1:" VerticalAlignment="Center"/>
<ColorPicker Grid.Row="1" Grid.Column="1" x:Name="Color1Picker" IsAlphaVisible="False"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Color 2:" VerticalAlignment="Center"/>
<ColorPicker Grid.Row="2" Grid.Column="1" x:Name="Color2Picker" IsAlphaVisible="False"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Param 1:" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="3" Grid.Column="1" x:Name="Param1"
Value="8" Minimum="1" Maximum="256" FormatString="0"
ToolTip.Tip="Cell/stripe/ring size"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Param 2:" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="4" Grid.Column="1" x:Name="Param2"
Value="8" Minimum="1" Maximum="256" FormatString="0"
ToolTip.Tip="Tile height (for Tile pattern)"/>
</Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,12,0,0">
<Button Content="Generate" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,50 @@
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);
}
}

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# Minint
> Растровый редактор с собственным бинарным форматом `.minint`, поддержкой слоёв, анимации и палитровых эффектов.
## Что это
Desktop-приложение для создания и редактирования пиксельной графики. Изображения хранятся в собственном бинарном формате с палитровой моделью, где каждый документ (кадр) содержит свою палитру RGBA и набор слоёв. Контейнер объединяет несколько документов для анимации.
## Возможности
- **Собственный формат `.minint`** — самописная бинарная сериализация с валидацией, переменным размером индексов палитры (14 байта)
- **Слои** — добавление, удаление, переименование, порядок, видимость, прозрачность, дублирование
- **Документы/кадры** — несколько документов в одном контейнере, каждый со своей палитрой
- **Инструменты** — кисть, ластик (с регулируемым радиусом), заливка (flood fill), выделение + копирование/вставка
- **Холст** — pan (среднее колёсико / touchpad), zoom (Ctrl+колёсико), nearest-neighbor масштабирование, пиксельная сетка
- **Эффекты** — контрастность (A1), перевод в градации серого (A2) — применяются через палитру
- **Копирование фрагмента (A4)** — визуальное выделение на холсте, плавающая вставка с поддержкой смены документа/слоя
- **Генерация узоров (Б4)** — шахматка, градиент, полосы, концентрические круги, плитка
- **Анимация** — проигрывание документов как кадров с настраиваемым delay
- **Экспорт** — BMP (32-bit BGRA, самописный), GIF (анимированный, LZW, самописный)
## Стек
| Компонент | Технология |
|-----------|------------|
| Язык | C# / .NET 10 |
| UI | Avalonia 11.3.8 |
| MVVM | CommunityToolkit.Mvvm 8.2.1 |
| Тесты | xUnit |
| Сериализация | Полностью самописная (бинарная) |
| Экспорт BMP/GIF | Полностью самописный |
## Структура проекта
```
Minint.slnx
├── Minint.Core/ — Доменные модели, интерфейсы сервисов, чистая логика
├── Minint.Infrastructure/ — Сериализация .minint, экспорт BMP/GIF
├── Minint/ — Avalonia UI приложение
└── Minint.Tests/ — Unit-тесты (xUnit)
```
Core не зависит ни от каких внешних пакетов. Infrastructure зависит только от Core. UI зависит от Core + Infrastructure + Avalonia.
## Требования
- [.NET 10 SDK](https://dotnet.microsoft.com/download) или новее
## Сборка и запуск
```bash
dotnet build
dotnet run --project Minint
```
## Тесты
```bash
dotnet test
```
37 тестов покрывают: сериализацию (round-trip), композитинг, инструменты рисования, flood fill, эффекты (контраст, grayscale), генерацию паттернов, копирование фрагмента, экспорт BMP/GIF.
## Управление
| Действие | Управление |
|----------|------------|
| Зум | Ctrl + колёсико мыши |
| Перемещение (pan) | Среднее колёсико / touchpad scroll |
| Рисование | Левая кнопка мыши |
| Пиксельная сетка | Ctrl+G или View > Pixel Grid |
| Копирование фрагмента | Select tool → выделить → Ctrl+C → Ctrl+V → кликнуть для фиксации |
| Отмена вставки | Escape |
| Новый файл | Ctrl+N |
| Открыть | Ctrl+O |
| Сохранить | Ctrl+S |
## Формат `.minint`
Подробная спецификация формата — в файле [`FORMAT.md`](FORMAT.md).
Ключевые свойства:
- Little-endian
- Сигнатура `MININT`, версия формата
- Палитра RGBA на документ, индексы переменной ширины (14 байта)
- Полная валидация при чтении
## Ограничения
- **Undo/Redo** не реализовано (архитектура не запрещает добавление)
- **Размер контейнера** — все документы имеют одинаковые размеры (width × height на уровне контейнера)
- **GIF квантизация** — простая popularity-based (до 256 цветов), может терять оттенки
- **Максимальный размер** — 65536 × 65536 пикселей (ограничение формата)
- Имена документов/слоёв — максимум 255 UTF-8 байт