Compare commits
7 Commits
3a61e0a07d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df6a581e2 | |||
| 63bd407cea | |||
| 64ab493037 | |||
| 770dd629f5 | |||
| 415b1a41fc | |||
| 0484aeae31 | |||
| e7546f9d12 |
@@ -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
122
FORMAT.md
Normal 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 (0–255) |
|
||||||
|
| 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
|
||||||
|
```
|
||||||
80
Minint.Infrastructure/Export/BmpExporter.cs
Normal file
80
Minint.Infrastructure/Export/BmpExporter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
329
Minint.Infrastructure/Export/GifExporter.cs
Normal file
329
Minint.Infrastructure/Export/GifExporter.cs
Normal 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 < 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
|
||||||
|
}
|
||||||
76
Minint.Tests/CompositorTests.cs
Normal file
76
Minint.Tests/CompositorTests.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Minint.Tests/DrawingTests.cs
Normal file
63
Minint.Tests/DrawingTests.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Minint.Tests/ExportTests.cs
Normal file
68
Minint.Tests/ExportTests.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Minint.Tests/FloodFillTests.cs
Normal file
52
Minint.Tests/FloodFillTests.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Minint.Tests/FragmentServiceTests.cs
Normal file
68
Minint.Tests/FragmentServiceTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Minint.Tests/ImageEffectsTests.cs
Normal file
65
Minint.Tests/ImageEffectsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Minint.Tests/Minint.Tests.csproj
Normal file
26
Minint.Tests/Minint.Tests.csproj
Normal 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>
|
||||||
41
Minint.Tests/PatternGeneratorTests.cs
Normal file
41
Minint.Tests/PatternGeneratorTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Minint.Tests/SerializerTests.cs
Normal file
121
Minint.Tests/SerializerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.ViewModels;
|
||||||
|
|
||||||
namespace Minint.Controls;
|
namespace Minint.Controls;
|
||||||
|
|
||||||
@@ -36,23 +38,30 @@ public class PixelCanvas : Control
|
|||||||
|
|
||||||
#region Events for tool interaction
|
#region Events for tool interaction
|
||||||
|
|
||||||
/// <summary>Fires when the user presses the left mouse button at an image pixel.</summary>
|
|
||||||
public event Action<int, int>? ToolDown;
|
public event Action<int, int>? ToolDown;
|
||||||
|
|
||||||
/// <summary>Fires when the user drags with left button held at an image pixel.</summary>
|
|
||||||
public event Action<int, int>? ToolDrag;
|
public event Action<int, int>? ToolDrag;
|
||||||
|
|
||||||
/// <summary>Fires when the cursor moves over the image (pixel coords, or null if outside).</summary>
|
|
||||||
public event Action<(int X, int Y)?>? CursorPixelChanged;
|
public event Action<(int X, int Y)?>? CursorPixelChanged;
|
||||||
|
|
||||||
/// <summary>Set by the host to provide preview mask pixels for overlay.</summary>
|
|
||||||
public Func<List<(int X, int Y)>?>? GetPreviewMask { get; set; }
|
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
|
#endregion
|
||||||
|
|
||||||
private readonly Viewport _viewport = new();
|
private readonly Viewport _viewport = new();
|
||||||
private bool _isPanning;
|
private bool _isPanning;
|
||||||
private bool _isDrawing;
|
private bool _isDrawing;
|
||||||
|
private bool _isSelecting;
|
||||||
private Point _panStart;
|
private Point _panStart;
|
||||||
private double _panStartOffsetX, _panStartOffsetY;
|
private double _panStartOffsetX, _panStartOffsetY;
|
||||||
private bool _viewportInitialized;
|
private bool _viewportInitialized;
|
||||||
@@ -116,10 +125,12 @@ public class PixelCanvas : Control
|
|||||||
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
|
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
|
||||||
context.DrawImage(bmp, srcRect, destRect);
|
context.DrawImage(bmp, srcRect, destRect);
|
||||||
|
|
||||||
if (ShowGrid && _viewport.Zoom >= 4)
|
if (ShowGrid)
|
||||||
DrawPixelGrid(context, imgW, imgH);
|
DrawPixelGrid(context, imgW, imgH);
|
||||||
|
|
||||||
DrawToolPreview(context, imgW, imgH);
|
DrawToolPreview(context, imgW, imgH);
|
||||||
|
DrawSelectionOverlay(context);
|
||||||
|
DrawPastePreview(context);
|
||||||
|
|
||||||
int w = imgW, h = imgH;
|
int w = imgW, h = imgH;
|
||||||
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
|
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
|
||||||
@@ -156,7 +167,11 @@ public class PixelCanvas : Control
|
|||||||
|
|
||||||
private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
|
private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
|
||||||
{
|
{
|
||||||
var pen = new Pen(new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)), 1);
|
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 clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||||
var imgRect = _viewport.ImageScreenRect(imgW, imgH);
|
var imgRect = _viewport.ImageScreenRect(imgW, imgH);
|
||||||
var visible = imgRect.Intersect(clip);
|
var visible = imgRect.Intersect(clip);
|
||||||
@@ -174,12 +189,14 @@ public class PixelCanvas : Control
|
|||||||
for (int px = startPx; px <= endPx; px++)
|
for (int px = startPx; px <= endPx; px++)
|
||||||
{
|
{
|
||||||
var (sx, _) = _viewport.PixelToScreen(px, 0);
|
var (sx, _) = _viewport.PixelToScreen(px, 0);
|
||||||
context.DrawLine(pen, new Point(sx, visible.Top), new Point(sx, visible.Bottom));
|
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++)
|
for (int py = startPy; py <= endPy; py++)
|
||||||
{
|
{
|
||||||
var (_, sy) = _viewport.PixelToScreen(0, py);
|
var (_, sy) = _viewport.PixelToScreen(0, py);
|
||||||
context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
|
double y = Math.Floor(sy) + 0.5;
|
||||||
|
context.DrawLine(pen, new Point(visible.Left, y), new Point(visible.Right, y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,11 +216,9 @@ public class PixelCanvas : Control
|
|||||||
foreach (var (px, py) in mask)
|
foreach (var (px, py) in mask)
|
||||||
{
|
{
|
||||||
var (sx, sy) = _viewport.PixelToScreen(px, py);
|
var (sx, sy) = _viewport.PixelToScreen(px, py);
|
||||||
var r = new Rect(sx, sy, zoom, zoom);
|
context.FillRectangle(previewBrush, new Rect(sx, sy, zoom, zoom));
|
||||||
context.FillRectangle(previewBrush, r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outline around the mask bounding box
|
|
||||||
if (mask.Count > 0)
|
if (mask.Count > 0)
|
||||||
{
|
{
|
||||||
int minX = mask[0].X, maxX = mask[0].X;
|
int minX = mask[0].X, maxX = mask[0].X;
|
||||||
@@ -216,12 +231,63 @@ public class PixelCanvas : Control
|
|||||||
if (py > maxY) maxY = py;
|
if (py > maxY) maxY = py;
|
||||||
}
|
}
|
||||||
var (ox, oy) = _viewport.PixelToScreen(minX, minY);
|
var (ox, oy) = _viewport.PixelToScreen(minX, minY);
|
||||||
var outlineRect = new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom);
|
context.DrawRectangle(outlinePen, new Rect(ox, oy, (maxX - minX + 1) * zoom, (maxY - minY + 1) * zoom));
|
||||||
context.DrawRectangle(outlinePen, outlineRect);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
#endregion
|
||||||
|
|
||||||
#region Scrollbar Sync
|
#region Scrollbar Sync
|
||||||
@@ -284,9 +350,6 @@ public class PixelCanvas : Control
|
|||||||
return (px, py);
|
return (px, py);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recalculates the pixel coordinate under the cursor after a viewport change.
|
|
||||||
/// </summary>
|
|
||||||
private void RecalcCursorPixel()
|
private void RecalcCursorPixel()
|
||||||
{
|
{
|
||||||
if (_lastScreenPos is null) return;
|
if (_lastScreenPos is null) return;
|
||||||
@@ -341,17 +404,35 @@ public class PixelCanvas : Control
|
|||||||
_panStartOffsetX = _viewport.OffsetX;
|
_panStartOffsetX = _viewport.OffsetX;
|
||||||
_panStartOffsetY = _viewport.OffsetY;
|
_panStartOffsetY = _viewport.OffsetY;
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else if (props.IsLeftButtonPressed && !_isPanning)
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
var pixel = ScreenToPixelClamped(e.GetPosition(this));
|
PasteCommitted?.Invoke();
|
||||||
if (pixel is not null)
|
e.Handled = true;
|
||||||
{
|
return;
|
||||||
_isDrawing = true;
|
|
||||||
ToolDown?.Invoke(pixel.Value.X, pixel.Value.Y);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
@@ -378,7 +459,24 @@ public class PixelCanvas : Control
|
|||||||
{
|
{
|
||||||
_lastCursorPixel = pixel;
|
_lastCursorPixel = pixel;
|
||||||
CursorPixelChanged?.Invoke(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();
|
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)
|
if (_isDrawing && pixel is not null)
|
||||||
@@ -386,6 +484,10 @@ public class PixelCanvas : Control
|
|||||||
ToolDrag?.Invoke(pixel.Value.X, pixel.Value.Y);
|
ToolDrag?.Invoke(pixel.Value.X, pixel.Value.Y);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||||
@@ -397,6 +499,15 @@ public class PixelCanvas : Control
|
|||||||
_isPanning = false;
|
_isPanning = false;
|
||||||
e.Handled = true;
|
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)
|
else if (_isDrawing && e.InitialPressMouseButton == MouseButton.Left)
|
||||||
{
|
{
|
||||||
_isDrawing = false;
|
_isDrawing = false;
|
||||||
@@ -416,6 +527,33 @@ public class PixelCanvas : Control
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
#endregion
|
||||||
|
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.ComponentModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Minint.Core.Models;
|
using Minint.Core.Models;
|
||||||
@@ -13,6 +14,11 @@ using Minint.Core.Services.Impl;
|
|||||||
|
|
||||||
namespace Minint.ViewModels;
|
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
|
public partial class EditorViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ICompositor _compositor = new Compositor();
|
private readonly ICompositor _compositor = new Compositor();
|
||||||
@@ -66,6 +72,27 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
|
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]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(Title))]
|
[NotifyPropertyChangedFor(nameof(Title))]
|
||||||
private string? _filePath;
|
private string? _filePath;
|
||||||
@@ -113,10 +140,6 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
SyncLayersAndCanvas(doc);
|
SyncLayersAndCanvas(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Re-syncs the Documents observable collection after an external modification
|
|
||||||
/// to Container.Documents (e.g. pattern generation adding a document).
|
|
||||||
/// </summary>
|
|
||||||
public void SyncAfterExternalChange() => SyncDocumentsList();
|
public void SyncAfterExternalChange() => SyncDocumentsList();
|
||||||
|
|
||||||
private void SyncDocumentsList()
|
private void SyncDocumentsList()
|
||||||
@@ -191,7 +214,7 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
private void RemoveDocument()
|
private void RemoveDocument()
|
||||||
{
|
{
|
||||||
if (Container is null || ActiveDocument is null) return;
|
if (Container is null || ActiveDocument is null) return;
|
||||||
if (Container.Documents.Count <= 1) return; // keep at least one
|
if (Container.Documents.Count <= 1) return;
|
||||||
|
|
||||||
var doc = ActiveDocument;
|
var doc = ActiveDocument;
|
||||||
int idx = Container.Documents.IndexOf(doc);
|
int idx = Container.Documents.IndexOf(doc);
|
||||||
@@ -203,10 +226,7 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void RenameDocument()
|
private void RenameDocument() { }
|
||||||
{
|
|
||||||
// Triggered via UI text edit — the Name property is directly editable via TextBox
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void MoveDocumentUp()
|
private void MoveDocumentUp()
|
||||||
@@ -254,7 +274,7 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
private void RemoveLayer()
|
private void RemoveLayer()
|
||||||
{
|
{
|
||||||
if (ActiveDocument is null || ActiveLayer is null) return;
|
if (ActiveDocument is null || ActiveLayer is null) return;
|
||||||
if (ActiveDocument.Layers.Count <= 1) return; // keep at least one
|
if (ActiveDocument.Layers.Count <= 1) return;
|
||||||
|
|
||||||
UnsubscribeLayerVisibility();
|
UnsubscribeLayerVisibility();
|
||||||
var layer = ActiveLayer;
|
var layer = ActiveLayer;
|
||||||
@@ -322,6 +342,7 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public void OnToolDown(int px, int py)
|
public void OnToolDown(int px, int py)
|
||||||
{
|
{
|
||||||
|
if (IsPlaying) return;
|
||||||
if (Container is null || ActiveDocument is null || ActiveLayer is null)
|
if (Container is null || ActiveDocument is null || ActiveLayer is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -329,6 +350,8 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
if (px < 0 || px >= w || py < 0 || py >= h)
|
if (px < 0 || px >= w || py < 0 || py >= h)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (ActiveTool == ToolType.Select) return; // handled separately
|
||||||
|
|
||||||
switch (ActiveTool)
|
switch (ActiveTool)
|
||||||
{
|
{
|
||||||
case ToolType.Brush:
|
case ToolType.Brush:
|
||||||
@@ -353,7 +376,8 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public void OnToolDrag(int px, int py)
|
public void OnToolDrag(int px, int py)
|
||||||
{
|
{
|
||||||
if (ActiveTool == ToolType.Fill) return;
|
if (IsPlaying) return;
|
||||||
|
if (ActiveTool is ToolType.Fill or ToolType.Select) return;
|
||||||
OnToolDown(px, py);
|
OnToolDown(px, py);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +385,7 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (PreviewCenter is null || Container is null)
|
if (PreviewCenter is null || Container is null)
|
||||||
return null;
|
return null;
|
||||||
if (ActiveTool == ToolType.Fill)
|
if (ActiveTool is ToolType.Fill or ToolType.Select)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var (cx, cy) = PreviewCenter.Value;
|
var (cx, cy) = PreviewCenter.Value;
|
||||||
@@ -369,36 +393,163 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectBrush() => ActiveTool = ToolType.Brush;
|
private void SelectBrush() { CancelPasteMode(); ActiveTool = ToolType.Brush; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectEraser() => ActiveTool = ToolType.Eraser;
|
private void SelectEraser() { CancelPasteMode(); ActiveTool = ToolType.Eraser; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectFill() => ActiveTool = ToolType.Fill;
|
private void SelectFill() { CancelPasteMode(); ActiveTool = ToolType.Fill; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SelectSelectTool() { CancelPasteMode(); ActiveTool = ToolType.Select; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Fragment copy (A4)
|
#region Selection + Copy/Paste (A4)
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Called by PixelCanvas when selection drag starts.</summary>
|
||||||
/// Copies a rectangular fragment from <paramref name="srcDoc"/> active layer
|
public void BeginSelection(int px, int py)
|
||||||
/// to <paramref name="dstDoc"/> active layer.
|
|
||||||
/// </summary>
|
|
||||||
public void CopyFragment(
|
|
||||||
MinintDocument srcDoc, int srcLayerIndex,
|
|
||||||
int srcX, int srcY, int regionW, int regionH,
|
|
||||||
MinintDocument dstDoc, int dstLayerIndex,
|
|
||||||
int dstX, int dstY)
|
|
||||||
{
|
{
|
||||||
if (Container is null) return;
|
if (IsPlaying) return;
|
||||||
_fragmentService.CopyFragment(
|
SelectionRect = (px, py, 0, 0);
|
||||||
srcDoc, srcLayerIndex, srcX, srcY, regionW, regionH,
|
}
|
||||||
dstDoc, dstLayerIndex, dstX, dstY,
|
|
||||||
Container.Width, Container.Height);
|
/// <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();
|
RefreshCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void CancelPaste()
|
||||||
|
{
|
||||||
|
PastePosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearSelection()
|
||||||
|
{
|
||||||
|
SelectionRect = null;
|
||||||
|
_selectionRectNormalized = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelPasteMode()
|
||||||
|
{
|
||||||
|
PastePosition = null;
|
||||||
|
SelectionRect = null;
|
||||||
|
_selectionRectNormalized = null;
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Canvas rendering
|
#region Canvas rendering
|
||||||
@@ -457,6 +608,127 @@ public partial class EditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
#endregion
|
#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 ICompositor Compositor => _compositor;
|
||||||
public IPaletteService PaletteService => _paletteService;
|
public IPaletteService PaletteService => _paletteService;
|
||||||
public IDrawingService DrawingService => _drawingService;
|
public IDrawingService DrawingService => _drawingService;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
@@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using Minint.Core.Models;
|
using Minint.Core.Models;
|
||||||
using Minint.Core.Services;
|
using Minint.Core.Services;
|
||||||
using Minint.Core.Services.Impl;
|
using Minint.Core.Services.Impl;
|
||||||
|
using Minint.Infrastructure.Export;
|
||||||
using Minint.Infrastructure.Serialization;
|
using Minint.Infrastructure.Serialization;
|
||||||
using Minint.Views;
|
using Minint.Views;
|
||||||
|
|
||||||
@@ -19,12 +20,25 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
private readonly MinintSerializer _serializer = new();
|
private readonly MinintSerializer _serializer = new();
|
||||||
private readonly IImageEffectsService _effects = new ImageEffectsService();
|
private readonly IImageEffectsService _effects = new ImageEffectsService();
|
||||||
private readonly IPatternGenerator _patternGen = new PatternGenerator();
|
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")
|
private static readonly FilePickerFileType MinintFileType = new("Minint Files")
|
||||||
{
|
{
|
||||||
Patterns = ["*.minint"],
|
Patterns = ["*.minint"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly FilePickerFileType BmpFileType = new("BMP Image")
|
||||||
|
{
|
||||||
|
Patterns = ["*.bmp"],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly FilePickerFileType GifFileType = new("GIF Animation")
|
||||||
|
{
|
||||||
|
Patterns = ["*.gif"],
|
||||||
|
};
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private EditorViewModel _editor = new();
|
private EditorViewModel _editor = new();
|
||||||
|
|
||||||
@@ -36,10 +50,18 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
#region File commands
|
#region File commands
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void NewFile()
|
private async Task NewFileAsync()
|
||||||
{
|
{
|
||||||
Editor.NewContainer(64, 64);
|
if (Owner is not Window window) return;
|
||||||
StatusText = "New 64×64 container created.";
|
|
||||||
|
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]
|
[RelayCommand]
|
||||||
@@ -153,49 +175,6 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Copy fragment (A4)
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task CopyFragmentAsync()
|
|
||||||
{
|
|
||||||
if (Editor.Container is null || Owner is not Window window) return;
|
|
||||||
|
|
||||||
var docs = Editor.Container.Documents;
|
|
||||||
if (docs.Count == 0) return;
|
|
||||||
|
|
||||||
var dialog = new CopyFragmentDialog(docs, Editor.Container.Width, Editor.Container.Height);
|
|
||||||
var result = await dialog.ShowDialog<bool?>(window);
|
|
||||||
if (result != true) return;
|
|
||||||
|
|
||||||
if (dialog.SourceDocument is null || dialog.DestDocument is null)
|
|
||||||
{
|
|
||||||
StatusText = "Copy failed: no document selected.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dialog.SourceLayerIndex < 0 || dialog.DestLayerIndex < 0)
|
|
||||||
{
|
|
||||||
StatusText = "Copy failed: no layer selected.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Editor.CopyFragment(
|
|
||||||
dialog.SourceDocument, dialog.SourceLayerIndex,
|
|
||||||
dialog.SourceX, dialog.SourceY,
|
|
||||||
dialog.FragmentWidth, dialog.FragmentHeight,
|
|
||||||
dialog.DestDocument, dialog.DestLayerIndex,
|
|
||||||
dialog.DestX, dialog.DestY);
|
|
||||||
StatusText = $"Copied {dialog.FragmentWidth}×{dialog.FragmentHeight} fragment.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusText = $"Copy failed: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Pattern generation (Б4)
|
#region Pattern generation (Б4)
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -229,4 +208,80 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum ToolType
|
|||||||
{
|
{
|
||||||
Brush,
|
Brush,
|
||||||
Eraser,
|
Eraser,
|
||||||
Fill
|
Fill,
|
||||||
|
Select
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static class ToolTypeConverters
|
|||||||
public static readonly IValueConverter IsBrush = new ToolTypeConverter(ToolType.Brush);
|
public static readonly IValueConverter IsBrush = new ToolTypeConverter(ToolType.Brush);
|
||||||
public static readonly IValueConverter IsEraser = new ToolTypeConverter(ToolType.Eraser);
|
public static readonly IValueConverter IsEraser = new ToolTypeConverter(ToolType.Eraser);
|
||||||
public static readonly IValueConverter IsFill = new ToolTypeConverter(ToolType.Fill);
|
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
|
private sealed class ToolTypeConverter(ToolType target) : IValueConverter
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
x:Class="Minint.Views.CopyFragmentDialog"
|
|
||||||
Title="Copy Fragment"
|
|
||||||
Width="380" Height="420"
|
|
||||||
WindowStartupLocation="CenterOwner"
|
|
||||||
CanResize="False"
|
|
||||||
SizeToContent="Height">
|
|
||||||
<StackPanel Margin="16" Spacing="8">
|
|
||||||
<TextBlock Text="Source" FontWeight="SemiBold"/>
|
|
||||||
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto" RowSpacing="4" ColumnSpacing="8">
|
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Document:" VerticalAlignment="Center"/>
|
|
||||||
<ComboBox Grid.Row="0" Grid.Column="1" x:Name="SrcDocCombo"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Layer:" VerticalAlignment="Center"/>
|
|
||||||
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="SrcLayerCombo"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="X:" VerticalAlignment="Center"/>
|
|
||||||
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="SrcX" Value="0" Minimum="0" FormatString="0"/>
|
|
||||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Y:" VerticalAlignment="Center"/>
|
|
||||||
<NumericUpDown Grid.Row="3" Grid.Column="1" x:Name="SrcY" Value="0" Minimum="0" FormatString="0"/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto" RowSpacing="4" ColumnSpacing="8" Margin="0,4,0,0">
|
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Width:" VerticalAlignment="Center"/>
|
|
||||||
<NumericUpDown Grid.Row="0" Grid.Column="1" x:Name="RegionW" Value="16" Minimum="1" FormatString="0"/>
|
|
||||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Height:" VerticalAlignment="Center"/>
|
|
||||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="RegionH" Value="16" Minimum="1" FormatString="0"/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Separator Margin="0,8"/>
|
|
||||||
<TextBlock Text="Destination" FontWeight="SemiBold"/>
|
|
||||||
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="4" ColumnSpacing="8">
|
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Document:" VerticalAlignment="Center"/>
|
|
||||||
<ComboBox Grid.Row="0" Grid.Column="1" x:Name="DstDocCombo"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Layer:" VerticalAlignment="Center"/>
|
|
||||||
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="DstLayerCombo"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="X:" VerticalAlignment="Center"/>
|
|
||||||
<NumericUpDown Grid.Row="2" Grid.Column="1" x:Name="DstX" Value="0" Minimum="0" FormatString="0"/>
|
|
||||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Y:" VerticalAlignment="Center"/>
|
|
||||||
<NumericUpDown Grid.Row="3" Grid.Column="1" x:Name="DstY" Value="0" Minimum="0" FormatString="0"/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,12,0,0">
|
|
||||||
<Button Content="Copy" x:Name="OkButton" IsDefault="True" Padding="16,6"/>
|
|
||||||
<Button Content="Cancel" x:Name="CancelButton" IsCancel="True" Padding="16,6"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Window>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Minint.Core.Models;
|
|
||||||
|
|
||||||
namespace Minint.Views;
|
|
||||||
|
|
||||||
public partial class CopyFragmentDialog : Window
|
|
||||||
{
|
|
||||||
private readonly List<MinintDocument> _documents;
|
|
||||||
|
|
||||||
public MinintDocument? SourceDocument => SrcDocCombo.SelectedItem as MinintDocument;
|
|
||||||
public int SourceLayerIndex => SrcLayerCombo.SelectedIndex;
|
|
||||||
public int SourceX => (int)(SrcX.Value ?? 0);
|
|
||||||
public int SourceY => (int)(SrcY.Value ?? 0);
|
|
||||||
public int FragmentWidth => (int)(RegionW.Value ?? 1);
|
|
||||||
public int FragmentHeight => (int)(RegionH.Value ?? 1);
|
|
||||||
public MinintDocument? DestDocument => DstDocCombo.SelectedItem as MinintDocument;
|
|
||||||
public int DestLayerIndex => DstLayerCombo.SelectedIndex;
|
|
||||||
public int DestX => (int)(DstX.Value ?? 0);
|
|
||||||
public int DestY => (int)(DstY.Value ?? 0);
|
|
||||||
|
|
||||||
public CopyFragmentDialog()
|
|
||||||
{
|
|
||||||
_documents = [];
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
public CopyFragmentDialog(List<MinintDocument> documents, int maxW, int maxH) : this()
|
|
||||||
{
|
|
||||||
_documents = documents;
|
|
||||||
|
|
||||||
SrcDocCombo.ItemsSource = documents;
|
|
||||||
SrcDocCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
|
||||||
DstDocCombo.ItemsSource = documents;
|
|
||||||
DstDocCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
|
||||||
|
|
||||||
SrcX.Maximum = maxW - 1;
|
|
||||||
SrcY.Maximum = maxH - 1;
|
|
||||||
DstX.Maximum = maxW - 1;
|
|
||||||
DstY.Maximum = maxH - 1;
|
|
||||||
RegionW.Maximum = maxW;
|
|
||||||
RegionH.Maximum = maxH;
|
|
||||||
|
|
||||||
if (documents.Count > 0)
|
|
||||||
{
|
|
||||||
SrcDocCombo.SelectedIndex = 0;
|
|
||||||
DstDocCombo.SelectedIndex = documents.Count > 1 ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
SrcDocCombo.SelectionChanged += (_, _) => UpdateSrcLayers();
|
|
||||||
DstDocCombo.SelectionChanged += (_, _) => UpdateDstLayers();
|
|
||||||
|
|
||||||
UpdateSrcLayers();
|
|
||||||
UpdateDstLayers();
|
|
||||||
|
|
||||||
OkButton.Click += OnOkClick;
|
|
||||||
CancelButton.Click += OnCancelClick;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSrcLayers()
|
|
||||||
{
|
|
||||||
if (SrcDocCombo.SelectedItem is MinintDocument doc)
|
|
||||||
{
|
|
||||||
SrcLayerCombo.ItemsSource = doc.Layers;
|
|
||||||
SrcLayerCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
|
||||||
if (doc.Layers.Count > 0) SrcLayerCombo.SelectedIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDstLayers()
|
|
||||||
{
|
|
||||||
if (DstDocCombo.SelectedItem is MinintDocument doc)
|
|
||||||
{
|
|
||||||
DstLayerCombo.ItemsSource = doc.Layers;
|
|
||||||
DstLayerCombo.DisplayMemberBinding = new Avalonia.Data.Binding("Name");
|
|
||||||
if (doc.Layers.Count > 0) DstLayerCombo.SelectedIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnOkClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
Close(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnCancelClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
Close(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,10 +25,19 @@
|
|||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
||||||
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+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>
|
||||||
<MenuItem Header="_Edit">
|
<MenuItem Header="_Edit">
|
||||||
<MenuItem Header="Copy _Fragment…" Command="{Binding CopyFragmentCommand}"
|
<MenuItem Header="_Copy" Command="{Binding Editor.CopySelectionCommand}"
|
||||||
ToolTip.Tip="Copy a rectangular region between documents"/>
|
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>
|
||||||
<MenuItem Header="_Image">
|
<MenuItem Header="_Image">
|
||||||
<MenuItem Header="Adjust _Contrast…" Command="{Binding ApplyContrastCommand}"
|
<MenuItem Header="Adjust _Contrast…" Command="{Binding ApplyContrastCommand}"
|
||||||
@@ -41,7 +50,7 @@
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="_View">
|
<MenuItem Header="_View">
|
||||||
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
||||||
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
|
IsChecked="{Binding Editor.ShowGrid, Mode=TwoWay}" HotKey="Ctrl+G"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
@@ -62,6 +71,10 @@
|
|||||||
ToolTip.Tip="Fill tool — flood fill with selected color"
|
ToolTip.Tip="Fill tool — flood fill with selected color"
|
||||||
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
|
IsChecked="{Binding Editor.ActiveTool, Converter={x:Static vm:ToolTypeConverters.IsFill}}"
|
||||||
Command="{Binding Editor.SelectFillCommand}"/>
|
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/>
|
<Separator/>
|
||||||
|
|
||||||
@@ -77,7 +90,7 @@
|
|||||||
<TextBlock Text="Color:" VerticalAlignment="Center"/>
|
<TextBlock Text="Color:" VerticalAlignment="Center"/>
|
||||||
<ColorPicker x:Name="ToolColorPicker"
|
<ColorPicker x:Name="ToolColorPicker"
|
||||||
Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
|
Color="{Binding Editor.PreviewColor, Mode=TwoWay}"
|
||||||
IsAlphaVisible="False"
|
IsAlphaVisible="True"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -96,6 +109,31 @@
|
|||||||
BorderThickness="0,0,1,0" Padding="4">
|
BorderThickness="0,0,1,0" Padding="4">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
<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">
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="2" Margin="0,4,0,0">
|
||||||
<Button Content="+" ToolTip.Tip="Add a new document (frame)"
|
<Button Content="+" ToolTip.Tip="Add a new document (frame)"
|
||||||
Command="{Binding Editor.AddDocumentCommand}" Padding="6,2"/>
|
Command="{Binding Editor.AddDocumentCommand}" Padding="6,2"/>
|
||||||
|
|||||||
@@ -37,9 +37,19 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private static void WireCanvasEvents(PixelCanvas canvas, EditorViewModel editor)
|
private static void WireCanvasEvents(PixelCanvas canvas, EditorViewModel editor)
|
||||||
{
|
{
|
||||||
|
canvas.Editor = editor;
|
||||||
|
|
||||||
canvas.ToolDown += (px, py) => editor.OnToolDown(px, py);
|
canvas.ToolDown += (px, py) => editor.OnToolDown(px, py);
|
||||||
canvas.ToolDrag += (px, py) => editor.OnToolDrag(px, py);
|
canvas.ToolDrag += (px, py) => editor.OnToolDrag(px, py);
|
||||||
canvas.CursorPixelChanged += pixel => editor.PreviewCenter = pixel;
|
canvas.CursorPixelChanged += pixel => editor.PreviewCenter = pixel;
|
||||||
canvas.GetPreviewMask = () => editor.GetPreviewMask();
|
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(); };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
Minint/Views/NewContainerDialog.axaml
Normal file
31
Minint/Views/NewContainerDialog.axaml
Normal 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>
|
||||||
17
Minint/Views/NewContainerDialog.axaml.cs
Normal file
17
Minint/Views/NewContainerDialog.axaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="Minint.Views.PatternDialog"
|
x:Class="Minint.Views.PatternDialog"
|
||||||
Title="Generate Pattern"
|
Title="Generate Pattern"
|
||||||
Width="360"
|
Width="640"
|
||||||
|
Height="520"
|
||||||
|
MinWidth="480"
|
||||||
|
MinHeight="400"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
CanResize="False"
|
CanResize="True">
|
||||||
SizeToContent="Height">
|
|
||||||
<StackPanel Margin="16" Spacing="8">
|
<StackPanel Margin="16" Spacing="8">
|
||||||
<Grid ColumnDefinitions="100,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="6" ColumnSpacing="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"/>
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Pattern:" VerticalAlignment="Center"/>
|
||||||
|
|||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Minint
|
||||||
|
|
||||||
|
> Растровый редактор с собственным бинарным форматом `.minint`, поддержкой слоёв, анимации и палитровых эффектов.
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Desktop-приложение для создания и редактирования пиксельной графики. Изображения хранятся в собственном бинарном формате с палитровой моделью, где каждый документ (кадр) содержит свою палитру RGBA и набор слоёв. Контейнер объединяет несколько документов для анимации.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Собственный формат `.minint`** — самописная бинарная сериализация с валидацией, переменным размером индексов палитры (1–4 байта)
|
||||||
|
- **Слои** — добавление, удаление, переименование, порядок, видимость, прозрачность, дублирование
|
||||||
|
- **Документы/кадры** — несколько документов в одном контейнере, каждый со своей палитрой
|
||||||
|
- **Инструменты** — кисть, ластик (с регулируемым радиусом), заливка (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 на документ, индексы переменной ширины (1–4 байта)
|
||||||
|
- Полная валидация при чтении
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- **Undo/Redo** не реализовано (архитектура не запрещает добавление)
|
||||||
|
- **Размер контейнера** — все документы имеют одинаковые размеры (width × height на уровне контейнера)
|
||||||
|
- **GIF квантизация** — простая popularity-based (до 256 цветов), может терять оттенки
|
||||||
|
- **Максимальный размер** — 65536 × 65536 пикселей (ограничение формата)
|
||||||
|
- Имена документов/слоёв — максимум 255 UTF-8 байт
|
||||||
Reference in New Issue
Block a user