Compare commits
2 Commits
5fdaaaa2bf
...
9ef5ad8f68
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ef5ad8f68 | |||
| 400af7982e |
@@ -21,6 +21,13 @@ public sealed class MinintDocument
|
|||||||
|
|
||||||
public List<MinintLayer> Layers { get; }
|
public List<MinintLayer> Layers { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse lookup cache: RgbaColor → palette index. Built lazily, invalidated
|
||||||
|
/// on structural palette changes (compact, clear). Call <see cref="InvalidatePaletteCache"/>
|
||||||
|
/// after bulk palette modifications.
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<RgbaColor, int>? _paletteCache;
|
||||||
|
|
||||||
public MinintDocument(string name)
|
public MinintDocument(string name)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
@@ -50,4 +57,48 @@ public sealed class MinintDocument
|
|||||||
<= 16_777_215 => 3,
|
<= 16_777_215 => 3,
|
||||||
_ => 4
|
_ => 4
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// O(1) lookup of a color in the palette. Returns the index, or -1 if not found.
|
||||||
|
/// Lazily builds an internal dictionary on first call.
|
||||||
|
/// </summary>
|
||||||
|
public int FindColorCached(RgbaColor color)
|
||||||
|
{
|
||||||
|
var cache = EnsurePaletteCache();
|
||||||
|
return cache.TryGetValue(color, out int idx) ? idx : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the index of <paramref name="color"/>. If absent, appends it to the palette
|
||||||
|
/// and updates the cache. O(1) amortized.
|
||||||
|
/// </summary>
|
||||||
|
public int EnsureColorCached(RgbaColor color)
|
||||||
|
{
|
||||||
|
var cache = EnsurePaletteCache();
|
||||||
|
if (cache.TryGetValue(color, out int idx))
|
||||||
|
return idx;
|
||||||
|
|
||||||
|
idx = Palette.Count;
|
||||||
|
Palette.Add(color);
|
||||||
|
cache[color] = idx;
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the reverse lookup cache. Must be called after any operation that
|
||||||
|
/// reorders, removes, or bulk-replaces palette entries (e.g. compact, grayscale).
|
||||||
|
/// </summary>
|
||||||
|
public void InvalidatePaletteCache() => _paletteCache = null;
|
||||||
|
|
||||||
|
private Dictionary<RgbaColor, int> EnsurePaletteCache()
|
||||||
|
{
|
||||||
|
if (_paletteCache is not null)
|
||||||
|
return _paletteCache;
|
||||||
|
|
||||||
|
var cache = new Dictionary<RgbaColor, int>(Palette.Count);
|
||||||
|
for (int i = 0; i < Palette.Count; i++)
|
||||||
|
cache.TryAdd(Palette[i], i); // first occurrence wins (for dupes)
|
||||||
|
_paletteCache = cache;
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,10 @@ namespace Minint.Core.Services.Impl;
|
|||||||
public sealed class PaletteService : IPaletteService
|
public sealed class PaletteService : IPaletteService
|
||||||
{
|
{
|
||||||
public int FindColor(MinintDocument document, RgbaColor color)
|
public int FindColor(MinintDocument document, RgbaColor color)
|
||||||
{
|
=> document.FindColorCached(color);
|
||||||
var palette = document.Palette;
|
|
||||||
for (int i = 0; i < palette.Count; i++)
|
|
||||||
{
|
|
||||||
if (palette[i] == color)
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int EnsureColor(MinintDocument document, RgbaColor color)
|
public int EnsureColor(MinintDocument document, RgbaColor color)
|
||||||
{
|
=> document.EnsureColorCached(color);
|
||||||
int idx = FindColor(document, color);
|
|
||||||
if (idx >= 0)
|
|
||||||
return idx;
|
|
||||||
|
|
||||||
idx = document.Palette.Count;
|
|
||||||
document.Palette.Add(color);
|
|
||||||
return idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CompactPalette(MinintDocument document)
|
public void CompactPalette(MinintDocument document)
|
||||||
{
|
{
|
||||||
@@ -32,19 +16,16 @@ public sealed class PaletteService : IPaletteService
|
|||||||
if (palette.Count <= 1)
|
if (palette.Count <= 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// 1. Collect indices actually used across all layers
|
var usedIndices = new HashSet<int> { 0 };
|
||||||
var usedIndices = new HashSet<int> { 0 }; // always keep transparent
|
|
||||||
foreach (var layer in document.Layers)
|
foreach (var layer in document.Layers)
|
||||||
{
|
{
|
||||||
foreach (int idx in layer.Pixels)
|
foreach (int idx in layer.Pixels)
|
||||||
usedIndices.Add(idx);
|
usedIndices.Add(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build new palette and old→new mapping
|
|
||||||
var oldToNew = new int[palette.Count];
|
var oldToNew = new int[palette.Count];
|
||||||
var newPalette = new List<RgbaColor>(usedIndices.Count);
|
var newPalette = new List<RgbaColor>(usedIndices.Count);
|
||||||
|
|
||||||
// Index 0 (transparent) stays at 0
|
|
||||||
newPalette.Add(palette[0]);
|
newPalette.Add(palette[0]);
|
||||||
oldToNew[0] = 0;
|
oldToNew[0] = 0;
|
||||||
|
|
||||||
@@ -55,23 +36,21 @@ public sealed class PaletteService : IPaletteService
|
|||||||
oldToNew[i] = newPalette.Count;
|
oldToNew[i] = newPalette.Count;
|
||||||
newPalette.Add(palette[i]);
|
newPalette.Add(palette[i]);
|
||||||
}
|
}
|
||||||
// unused indices don't get a mapping — they'll never be looked up
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If nothing was removed, skip the remap
|
|
||||||
if (newPalette.Count == palette.Count)
|
if (newPalette.Count == palette.Count)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// 4. Replace palette
|
|
||||||
palette.Clear();
|
palette.Clear();
|
||||||
palette.AddRange(newPalette);
|
palette.AddRange(newPalette);
|
||||||
|
|
||||||
// 5. Remap all pixel arrays
|
|
||||||
foreach (var layer in document.Layers)
|
foreach (var layer in document.Layers)
|
||||||
{
|
{
|
||||||
var px = layer.Pixels;
|
var px = layer.Pixels;
|
||||||
for (int i = 0; i < px.Length; i++)
|
for (int i = 0; i < px.Length; i++)
|
||||||
px[i] = oldToNew[px[i]];
|
px[i] = oldToNew[px[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.InvalidatePaletteCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
325
Minint/Controls/PixelCanvas.cs
Normal file
325
Minint/Controls/PixelCanvas.cs
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace Minint.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom).
|
||||||
|
/// Supports nearest-neighbor scaling, pixel grid overlay, scrollbars, and mouse interaction.
|
||||||
|
/// <para>Input model:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Ctrl+Wheel: zoom at cursor</item>
|
||||||
|
/// <item>Wheel: scroll vertically</item>
|
||||||
|
/// <item>Shift+Wheel: scroll horizontally</item>
|
||||||
|
/// <item>Touchpad two-finger scroll: free pan (Delta.X + Delta.Y)</item>
|
||||||
|
/// <item>Middle mouse drag: pan</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class PixelCanvas : Control
|
||||||
|
{
|
||||||
|
#region Styled Properties
|
||||||
|
|
||||||
|
public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty =
|
||||||
|
AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> ShowGridProperty =
|
||||||
|
AvaloniaProperty.Register<PixelCanvas, bool>(nameof(ShowGrid), defaultValue: false);
|
||||||
|
|
||||||
|
public WriteableBitmap? SourceBitmap
|
||||||
|
{
|
||||||
|
get => GetValue(SourceBitmapProperty);
|
||||||
|
set => SetValue(SourceBitmapProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShowGrid
|
||||||
|
{
|
||||||
|
get => GetValue(ShowGridProperty);
|
||||||
|
set => SetValue(ShowGridProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private readonly Viewport _viewport = new();
|
||||||
|
private bool _isPanning;
|
||||||
|
private Point _panStart;
|
||||||
|
private double _panStartOffsetX, _panStartOffsetY;
|
||||||
|
private bool _viewportInitialized;
|
||||||
|
|
||||||
|
private ScrollBar? _hScrollBar;
|
||||||
|
private ScrollBar? _vScrollBar;
|
||||||
|
private bool _suppressScrollSync;
|
||||||
|
|
||||||
|
private const double ScrollPixelsPerTick = 20.0;
|
||||||
|
|
||||||
|
public Viewport Viewport => _viewport;
|
||||||
|
|
||||||
|
static PixelCanvas()
|
||||||
|
{
|
||||||
|
AffectsRender<PixelCanvas>(SourceBitmapProperty, ShowGridProperty);
|
||||||
|
FocusableProperty.OverrideDefaultValue<PixelCanvas>(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PixelCanvas()
|
||||||
|
{
|
||||||
|
ClipToBounds = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects external ScrollBar controls. Call once after the UI is built.
|
||||||
|
/// </summary>
|
||||||
|
public void AttachScrollBars(ScrollBar horizontal, ScrollBar vertical)
|
||||||
|
{
|
||||||
|
if (_hScrollBar is not null)
|
||||||
|
_hScrollBar.ValueChanged -= OnHScrollChanged;
|
||||||
|
if (_vScrollBar is not null)
|
||||||
|
_vScrollBar.ValueChanged -= OnVScrollChanged;
|
||||||
|
|
||||||
|
_hScrollBar = horizontal;
|
||||||
|
_vScrollBar = vertical;
|
||||||
|
|
||||||
|
_hScrollBar.ValueChanged += OnHScrollChanged;
|
||||||
|
_vScrollBar.ValueChanged += OnVScrollChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Rendering
|
||||||
|
|
||||||
|
public override void Render(DrawingContext context)
|
||||||
|
{
|
||||||
|
base.Render(context);
|
||||||
|
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
||||||
|
|
||||||
|
var bmp = SourceBitmap;
|
||||||
|
if (bmp is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int imgW = bmp.PixelSize.Width;
|
||||||
|
int imgH = bmp.PixelSize.Height;
|
||||||
|
|
||||||
|
if (!_viewportInitialized)
|
||||||
|
{
|
||||||
|
_viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
_viewportInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawCheckerboard(context, imgW, imgH);
|
||||||
|
|
||||||
|
var destRect = _viewport.ImageScreenRect(imgW, imgH);
|
||||||
|
var srcRect = new Rect(0, 0, imgW, imgH);
|
||||||
|
RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.None);
|
||||||
|
context.DrawImage(bmp, srcRect, destRect);
|
||||||
|
|
||||||
|
if (ShowGrid && _viewport.Zoom >= 4)
|
||||||
|
DrawPixelGrid(context, imgW, imgH);
|
||||||
|
|
||||||
|
// Defer scrollbar sync — updating layout properties during Render is forbidden
|
||||||
|
int w = imgW, h = imgH;
|
||||||
|
Dispatcher.UIThread.Post(() => SyncScrollBars(w, h), DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCheckerboard(DrawingContext context, int imgW, int imgH)
|
||||||
|
{
|
||||||
|
var rect = _viewport.ImageScreenRect(imgW, imgH);
|
||||||
|
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||||
|
var visible = rect.Intersect(clip);
|
||||||
|
if (visible.Width <= 0 || visible.Height <= 0) return;
|
||||||
|
|
||||||
|
const int checkerSize = 8;
|
||||||
|
var light = new SolidColorBrush(Color.FromRgb(204, 204, 204));
|
||||||
|
var dark = new SolidColorBrush(Color.FromRgb(170, 170, 170));
|
||||||
|
|
||||||
|
using (context.PushClip(visible))
|
||||||
|
{
|
||||||
|
context.FillRectangle(light, visible);
|
||||||
|
|
||||||
|
double startX = visible.X - ((visible.X - rect.X) % (checkerSize * 2));
|
||||||
|
double startY = visible.Y - ((visible.Y - rect.Y) % (checkerSize * 2));
|
||||||
|
|
||||||
|
for (double y = startY; y < visible.Bottom; y += checkerSize)
|
||||||
|
{
|
||||||
|
for (double x = startX; x < visible.Right; x += checkerSize)
|
||||||
|
{
|
||||||
|
int col = (int)((x - rect.X) / checkerSize);
|
||||||
|
int row = (int)((y - rect.Y) / checkerSize);
|
||||||
|
if ((col + row) % 2 == 1)
|
||||||
|
context.FillRectangle(dark, new Rect(x, y, checkerSize, checkerSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawPixelGrid(DrawingContext context, int imgW, int imgH)
|
||||||
|
{
|
||||||
|
var pen = new Pen(new SolidColorBrush(Color.FromArgb(60, 255, 255, 255)), 1);
|
||||||
|
var clip = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
||||||
|
var imgRect = _viewport.ImageScreenRect(imgW, imgH);
|
||||||
|
var visible = imgRect.Intersect(clip);
|
||||||
|
if (visible.Width <= 0 || visible.Height <= 0) return;
|
||||||
|
|
||||||
|
var (startPx, startPy) = _viewport.ScreenToPixel(visible.X, visible.Y);
|
||||||
|
var (endPx, endPy) = _viewport.ScreenToPixel(visible.Right, visible.Bottom);
|
||||||
|
startPx = Math.Max(0, startPx);
|
||||||
|
startPy = Math.Max(0, startPy);
|
||||||
|
endPx = Math.Min(imgW, endPx + 1);
|
||||||
|
endPy = Math.Min(imgH, endPy + 1);
|
||||||
|
|
||||||
|
using (context.PushClip(visible))
|
||||||
|
{
|
||||||
|
for (int px = startPx; px <= endPx; px++)
|
||||||
|
{
|
||||||
|
var (sx, _) = _viewport.PixelToScreen(px, 0);
|
||||||
|
context.DrawLine(pen, new Point(sx, visible.Top), new Point(sx, visible.Bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int py = startPy; py <= endPy; py++)
|
||||||
|
{
|
||||||
|
var (_, sy) = _viewport.PixelToScreen(0, py);
|
||||||
|
context.DrawLine(pen, new Point(visible.Left, sy), new Point(visible.Right, sy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Scrollbar Sync
|
||||||
|
|
||||||
|
private void SyncScrollBars(int imgW, int imgH)
|
||||||
|
{
|
||||||
|
if (_hScrollBar is null || _vScrollBar is null) return;
|
||||||
|
|
||||||
|
_suppressScrollSync = true;
|
||||||
|
|
||||||
|
// Scrollbar value is negated offset: increasing value = scroll right = offset decreases
|
||||||
|
var (hMin, hMax, hVal, hView) = _viewport.GetScrollInfo(imgW, Bounds.Width, _viewport.OffsetX);
|
||||||
|
_hScrollBar.Minimum = -hMax;
|
||||||
|
_hScrollBar.Maximum = -hMin;
|
||||||
|
_hScrollBar.Value = -hVal;
|
||||||
|
_hScrollBar.ViewportSize = hView;
|
||||||
|
|
||||||
|
var (vMin, vMax, vVal, vView) = _viewport.GetScrollInfo(imgH, Bounds.Height, _viewport.OffsetY);
|
||||||
|
_vScrollBar.Minimum = -vMax;
|
||||||
|
_vScrollBar.Maximum = -vMin;
|
||||||
|
_vScrollBar.Value = -vVal;
|
||||||
|
_vScrollBar.ViewportSize = vView;
|
||||||
|
|
||||||
|
_suppressScrollSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressScrollSync) return;
|
||||||
|
var (imgW, imgH) = GetImageSize();
|
||||||
|
_viewport.SetOffset(-e.NewValue, _viewport.OffsetY,
|
||||||
|
imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVScrollChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressScrollSync) return;
|
||||||
|
var (imgW, imgH) = GetImageSize();
|
||||||
|
_viewport.SetOffset(_viewport.OffsetX, -e.NewValue,
|
||||||
|
imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Mouse Input
|
||||||
|
|
||||||
|
private (int W, int H) GetImageSize()
|
||||||
|
{
|
||||||
|
var bmp = SourceBitmap;
|
||||||
|
return bmp is not null ? (bmp.PixelSize.Width, bmp.PixelSize.Height) : (0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerWheelChanged(e);
|
||||||
|
var (imgW, imgH) = GetImageSize();
|
||||||
|
if (imgW == 0) return;
|
||||||
|
|
||||||
|
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
|
||||||
|
bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
|
||||||
|
|
||||||
|
if (ctrl)
|
||||||
|
{
|
||||||
|
var pos = e.GetPosition(this);
|
||||||
|
_viewport.ZoomAtPoint(pos.X, pos.Y, e.Delta.Y,
|
||||||
|
imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
double dx = e.Delta.X * ScrollPixelsPerTick;
|
||||||
|
double dy = e.Delta.Y * ScrollPixelsPerTick;
|
||||||
|
|
||||||
|
if (shift && Math.Abs(e.Delta.X) < 0.001)
|
||||||
|
{
|
||||||
|
dx = dy;
|
||||||
|
dy = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidateVisual();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerPressed(e);
|
||||||
|
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed)
|
||||||
|
{
|
||||||
|
_isPanning = true;
|
||||||
|
_panStart = e.GetPosition(this);
|
||||||
|
_panStartOffsetX = _viewport.OffsetX;
|
||||||
|
_panStartOffsetY = _viewport.OffsetY;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerMoved(e);
|
||||||
|
|
||||||
|
if (_isPanning)
|
||||||
|
{
|
||||||
|
var pos = e.GetPosition(this);
|
||||||
|
var (imgW, imgH) = GetImageSize();
|
||||||
|
_viewport.SetOffset(
|
||||||
|
_panStartOffsetX + (pos.X - _panStart.X),
|
||||||
|
_panStartOffsetY + (pos.Y - _panStart.Y),
|
||||||
|
imgW, imgH, Bounds.Width, Bounds.Height);
|
||||||
|
InvalidateVisual();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerReleased(e);
|
||||||
|
|
||||||
|
if (_isPanning && e.InitialPressMouseButton == MouseButton.Middle)
|
||||||
|
{
|
||||||
|
_isPanning = false;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
|
||||||
|
if (change.Property == SourceBitmapProperty)
|
||||||
|
_viewportInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
Minint/Controls/Viewport.cs
Normal file
119
Minint/Controls/Viewport.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace Minint.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages zoom level and pan offset for the pixel canvas.
|
||||||
|
/// Provides screen↔pixel coordinate transforms.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Viewport
|
||||||
|
{
|
||||||
|
public double Zoom { get; set; } = 1.0;
|
||||||
|
public double OffsetX { get; set; }
|
||||||
|
public double OffsetY { get; set; }
|
||||||
|
|
||||||
|
public const double MinZoom = 0.25;
|
||||||
|
public const double MaxZoom = 128.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zoom base per 1.0 unit of wheel delta. Actual factor = Pow(base, |delta|).
|
||||||
|
/// Touchpad nudge (delta ~0.1) → ~1.01×, mouse tick (delta 1.0) → 1.10×, fast (3.0) → 1.33×.
|
||||||
|
/// </summary>
|
||||||
|
private const double ZoomBase = 1.10;
|
||||||
|
|
||||||
|
public (int X, int Y) ScreenToPixel(double screenX, double screenY) =>
|
||||||
|
((int)Math.Floor((screenX - OffsetX) / Zoom),
|
||||||
|
(int)Math.Floor((screenY - OffsetY) / Zoom));
|
||||||
|
|
||||||
|
public (double X, double Y) PixelToScreen(int pixelX, int pixelY) =>
|
||||||
|
(pixelX * Zoom + OffsetX,
|
||||||
|
pixelY * Zoom + OffsetY);
|
||||||
|
|
||||||
|
public Rect ImageScreenRect(int imageWidth, int imageHeight) =>
|
||||||
|
new(OffsetX, OffsetY, imageWidth * Zoom, imageHeight * Zoom);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zooms keeping the point under cursor fixed.
|
||||||
|
/// Uses the actual magnitude of <paramref name="delta"/> for proportional zoom.
|
||||||
|
/// </summary>
|
||||||
|
public void ZoomAtPoint(double screenX, double screenY, double delta,
|
||||||
|
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
|
||||||
|
{
|
||||||
|
double absDelta = Math.Abs(delta);
|
||||||
|
double factor = delta > 0 ? Math.Pow(ZoomBase, absDelta) : 1.0 / Math.Pow(ZoomBase, absDelta);
|
||||||
|
double newZoom = Math.Clamp(Zoom * factor, MinZoom, MaxZoom);
|
||||||
|
if (Math.Abs(newZoom - Zoom) < 1e-12) return;
|
||||||
|
|
||||||
|
double pixelX = (screenX - OffsetX) / Zoom;
|
||||||
|
double pixelY = (screenY - OffsetY) / Zoom;
|
||||||
|
Zoom = newZoom;
|
||||||
|
OffsetX = screenX - pixelX * Zoom;
|
||||||
|
OffsetY = screenY - pixelY * Zoom;
|
||||||
|
|
||||||
|
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pans by screen-space delta, then clamps so the image can't be scrolled out of view.
|
||||||
|
/// </summary>
|
||||||
|
public void Pan(double deltaX, double deltaY,
|
||||||
|
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
|
||||||
|
{
|
||||||
|
OffsetX += deltaX;
|
||||||
|
OffsetY += deltaY;
|
||||||
|
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets offset directly (e.g. from middle-mouse drag), then clamps.
|
||||||
|
/// </summary>
|
||||||
|
public void SetOffset(double offsetX, double offsetY,
|
||||||
|
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
|
||||||
|
{
|
||||||
|
OffsetX = offsetX;
|
||||||
|
OffsetY = offsetY;
|
||||||
|
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures at least <c>minVisible</c> pixels of the image remain on screen on each edge.
|
||||||
|
/// </summary>
|
||||||
|
public void ClampOffset(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
|
||||||
|
{
|
||||||
|
double extentW = imageWidth * Zoom;
|
||||||
|
double extentH = imageHeight * Zoom;
|
||||||
|
|
||||||
|
double minVisH = Math.Max(32, Math.Min(controlWidth, extentW) * 0.10);
|
||||||
|
double minVisV = Math.Max(32, Math.Min(controlHeight, extentH) * 0.10);
|
||||||
|
|
||||||
|
// Image right edge must be >= minVisH from left of control
|
||||||
|
// Image left edge must be <= controlWidth - minVisH from left
|
||||||
|
OffsetX = Math.Clamp(OffsetX, minVisH - extentW, controlWidth - minVisH);
|
||||||
|
OffsetY = Math.Clamp(OffsetY, minVisV - extentH, controlHeight - minVisV);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FitToView(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
|
||||||
|
{
|
||||||
|
if (imageWidth <= 0 || imageHeight <= 0 || controlWidth <= 0 || controlHeight <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
double scaleX = controlWidth / imageWidth;
|
||||||
|
double scaleY = controlHeight / imageHeight;
|
||||||
|
Zoom = Math.Max(1.0, Math.Floor(Math.Min(scaleX, scaleY)));
|
||||||
|
|
||||||
|
OffsetX = (controlWidth - imageWidth * Zoom) / 2.0;
|
||||||
|
OffsetY = (controlHeight - imageHeight * Zoom) / 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public (double Min, double Max, double Value, double ViewportSize)
|
||||||
|
GetScrollInfo(int imageSize, double controlSize, double offset)
|
||||||
|
{
|
||||||
|
double extent = imageSize * Zoom;
|
||||||
|
double minVis = Math.Max(32, Math.Min(controlSize, extent) * 0.10);
|
||||||
|
double min = minVis - extent;
|
||||||
|
double max = controlSize - minVis;
|
||||||
|
double viewportSize = Math.Min(controlSize, extent);
|
||||||
|
return (min, max, offset, viewportSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
|||||||
163
Minint/ViewModels/EditorViewModel.cs
Normal file
163
Minint/ViewModels/EditorViewModel.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Minint.Core.Models;
|
||||||
|
using Minint.Core.Services;
|
||||||
|
using Minint.Core.Services.Impl;
|
||||||
|
|
||||||
|
namespace Minint.ViewModels;
|
||||||
|
|
||||||
|
public partial class EditorViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ICompositor _compositor = new Compositor();
|
||||||
|
private readonly IPaletteService _paletteService = new PaletteService();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasContainer))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Title))]
|
||||||
|
private MinintContainer? _container;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private MinintDocument? _activeDocument;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private MinintLayer? _activeLayer;
|
||||||
|
|
||||||
|
private bool _suppressDocumentSync;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private WriteableBitmap? _canvasBitmap;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _showGrid;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path of the currently open file, or null for unsaved new containers.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Title))]
|
||||||
|
private string? _filePath;
|
||||||
|
|
||||||
|
public bool HasContainer => Container is not null;
|
||||||
|
|
||||||
|
public string Title => FilePath is not null
|
||||||
|
? $"Minint — {System.IO.Path.GetFileName(FilePath)}"
|
||||||
|
: Container is not null
|
||||||
|
? "Minint — Untitled"
|
||||||
|
: "Minint";
|
||||||
|
|
||||||
|
public ObservableCollection<MinintDocument> Documents { get; } = [];
|
||||||
|
public ObservableCollection<MinintLayer> Layers { get; } = [];
|
||||||
|
|
||||||
|
public void NewContainer(int width, int height)
|
||||||
|
{
|
||||||
|
var c = new MinintContainer(width, height);
|
||||||
|
c.AddNewDocument("Document 1");
|
||||||
|
LoadContainer(c, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadContainer(MinintContainer container, string? path)
|
||||||
|
{
|
||||||
|
Container = container;
|
||||||
|
FilePath = path;
|
||||||
|
|
||||||
|
Documents.Clear();
|
||||||
|
foreach (var doc in container.Documents)
|
||||||
|
Documents.Add(doc);
|
||||||
|
|
||||||
|
SelectDocument(container.Documents.Count > 0 ? container.Documents[0] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by CommunityToolkit when ActiveDocument property changes (e.g. from ListBox binding).
|
||||||
|
/// </summary>
|
||||||
|
partial void OnActiveDocumentChanged(MinintDocument? value)
|
||||||
|
{
|
||||||
|
if (_suppressDocumentSync) return;
|
||||||
|
SyncLayersAndCanvas(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SelectDocument(MinintDocument? doc)
|
||||||
|
{
|
||||||
|
_suppressDocumentSync = true;
|
||||||
|
ActiveDocument = doc;
|
||||||
|
_suppressDocumentSync = false;
|
||||||
|
|
||||||
|
SyncLayersAndCanvas(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncLayersAndCanvas(MinintDocument? doc)
|
||||||
|
{
|
||||||
|
Layers.Clear();
|
||||||
|
if (doc is not null)
|
||||||
|
{
|
||||||
|
foreach (var layer in doc.Layers)
|
||||||
|
Layers.Add(layer);
|
||||||
|
ActiveLayer = doc.Layers.Count > 0 ? doc.Layers[0] : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ActiveLayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshCanvas()
|
||||||
|
{
|
||||||
|
if (Container is null || ActiveDocument is null)
|
||||||
|
{
|
||||||
|
CanvasBitmap = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int w = Container.Width;
|
||||||
|
int h = Container.Height;
|
||||||
|
uint[] argb = _compositor.Composite(ActiveDocument, w, h);
|
||||||
|
|
||||||
|
var bmp = new WriteableBitmap(
|
||||||
|
new PixelSize(w, h),
|
||||||
|
new Vector(96, 96),
|
||||||
|
Avalonia.Platform.PixelFormat.Bgra8888);
|
||||||
|
|
||||||
|
using (var fb = bmp.Lock())
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var dst = new Span<uint>((void*)fb.Address, w * h);
|
||||||
|
for (int i = 0; i < argb.Length; i++)
|
||||||
|
{
|
||||||
|
// argb[i] is 0xAARRGGBB, need premultiplied BGRA for the bitmap
|
||||||
|
uint px = argb[i];
|
||||||
|
byte a = (byte)(px >> 24);
|
||||||
|
byte r = (byte)((px >> 16) & 0xFF);
|
||||||
|
byte g = (byte)((px >> 8) & 0xFF);
|
||||||
|
byte b = (byte)(px & 0xFF);
|
||||||
|
|
||||||
|
if (a == 255)
|
||||||
|
{
|
||||||
|
dst[i] = px; // ARGB layout == BGRA in LE memory, alpha=255 → no premul needed
|
||||||
|
}
|
||||||
|
else if (a == 0)
|
||||||
|
{
|
||||||
|
dst[i] = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
r = (byte)(r * a / 255);
|
||||||
|
g = (byte)(g * a / 255);
|
||||||
|
b = (byte)(b * a / 255);
|
||||||
|
dst[i] = (uint)(b | (g << 8) | (r << 16) | (a << 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasBitmap = bmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICompositor Compositor => _compositor;
|
||||||
|
public IPaletteService PaletteService => _paletteService;
|
||||||
|
}
|
||||||
@@ -1,6 +1,124 @@
|
|||||||
namespace Minint.ViewModels;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Minint.Infrastructure.Serialization;
|
||||||
|
|
||||||
|
namespace Minint.ViewModels;
|
||||||
|
|
||||||
public partial class MainWindowViewModel : ViewModelBase
|
public partial class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public string Greeting { get; } = "Welcome to Avalonia!";
|
private readonly MinintSerializer _serializer = new();
|
||||||
|
|
||||||
|
private static readonly FilePickerFileType MinintFileType = new("Minint Files")
|
||||||
|
{
|
||||||
|
Patterns = ["*.minint"],
|
||||||
|
};
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private EditorViewModel _editor = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _statusText = "Ready";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set by the view so that file dialogs can use the correct parent window.
|
||||||
|
/// </summary>
|
||||||
|
public TopLevel? Owner { get; set; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void NewFile()
|
||||||
|
{
|
||||||
|
Editor.NewContainer(64, 64);
|
||||||
|
StatusText = "New 64×64 container created.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenFileAsync()
|
||||||
|
{
|
||||||
|
if (Owner?.StorageProvider is not { } sp) return;
|
||||||
|
|
||||||
|
var files = await sp.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Open .minint file",
|
||||||
|
FileTypeFilter = [MinintFileType],
|
||||||
|
AllowMultiple = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
|
var file = files[0];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = await file.OpenReadAsync();
|
||||||
|
var container = _serializer.Read(stream);
|
||||||
|
var path = file.TryGetLocalPath();
|
||||||
|
Editor.LoadContainer(container, path);
|
||||||
|
StatusText = $"Opened {file.Name}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText = $"Error opening file: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveFileAsync()
|
||||||
|
{
|
||||||
|
if (Editor.Container is null) return;
|
||||||
|
|
||||||
|
if (Editor.FilePath is not null)
|
||||||
|
{
|
||||||
|
await SaveToPathAsync(Editor.FilePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SaveFileAsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveFileAsAsync()
|
||||||
|
{
|
||||||
|
if (Owner?.StorageProvider is not { } sp || Editor.Container is null) return;
|
||||||
|
|
||||||
|
var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||||
|
{
|
||||||
|
Title = "Save .minint file",
|
||||||
|
DefaultExtension = "minint",
|
||||||
|
FileTypeChoices = [MinintFileType],
|
||||||
|
SuggestedFileName = Editor.FilePath is not null
|
||||||
|
? Path.GetFileName(Editor.FilePath) : "untitled.minint",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (file is null) return;
|
||||||
|
|
||||||
|
var path = file.TryGetLocalPath();
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
StatusText = "Error: could not resolve file path.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SaveToPathAsync(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveToPathAsync(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var fs = File.Create(path);
|
||||||
|
_serializer.Write(fs, Editor.Container!);
|
||||||
|
Editor.FilePath = path;
|
||||||
|
StatusText = $"Saved {Path.GetFileName(path)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusText = $"Error saving file: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,95 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:Minint.ViewModels"
|
xmlns:vm="using:Minint.ViewModels"
|
||||||
|
xmlns:controls="using:Minint.Controls"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="1024" d:DesignHeight="700"
|
||||||
x:Class="Minint.Views.MainWindow"
|
x:Class="Minint.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Icon="/Assets/avalonia-logo.ico"
|
Icon="/Assets/avalonia-logo.ico"
|
||||||
Title="Minint">
|
Title="{Binding Editor.Title}"
|
||||||
|
Width="1024" Height="700">
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
|
||||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
|
||||||
<vm:MainWindowViewModel/>
|
<vm:MainWindowViewModel/>
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
<DockPanel>
|
||||||
|
<!-- Menu bar -->
|
||||||
|
<Menu DockPanel.Dock="Top">
|
||||||
|
<MenuItem Header="_File">
|
||||||
|
<MenuItem Header="_New" Command="{Binding NewFileCommand}" HotKey="Ctrl+N"/>
|
||||||
|
<MenuItem Header="_Open…" Command="{Binding OpenFileCommand}" HotKey="Ctrl+O"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="_Save" Command="{Binding SaveFileCommand}" HotKey="Ctrl+S"/>
|
||||||
|
<MenuItem Header="Save _As…" Command="{Binding SaveFileAsCommand}" HotKey="Ctrl+Shift+S"/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_View">
|
||||||
|
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
|
||||||
|
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<Border DockPanel.Dock="Bottom" Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
|
||||||
|
Padding="8,2">
|
||||||
|
<TextBlock Text="{Binding StatusText}" FontSize="12"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Main content: left panel, canvas, right panel -->
|
||||||
|
<Grid ColumnDefinitions="180,*,180">
|
||||||
|
|
||||||
|
<!-- Left panel: documents -->
|
||||||
|
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="0,0,1,0" Padding="4">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="Documents" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<ListBox ItemsSource="{Binding Editor.Documents}"
|
||||||
|
SelectedItem="{Binding Editor.ActiveDocument}"
|
||||||
|
SelectionMode="Single">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Center: canvas with scrollbars -->
|
||||||
|
<Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto">
|
||||||
|
<Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True">
|
||||||
|
<controls:PixelCanvas x:Name="Canvas"
|
||||||
|
SourceBitmap="{Binding Editor.CanvasBitmap}"
|
||||||
|
ShowGrid="{Binding Editor.ShowGrid}"/>
|
||||||
|
</Border>
|
||||||
|
<ScrollBar x:Name="HScroll" Grid.Row="1" Grid.Column="0"
|
||||||
|
Orientation="Horizontal"/>
|
||||||
|
<ScrollBar x:Name="VScroll" Grid.Row="0" Grid.Column="1"
|
||||||
|
Orientation="Vertical"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Right panel: layers -->
|
||||||
|
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
|
BorderThickness="1,0,0,0" Padding="4">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="Layers" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||||
|
<ListBox ItemsSource="{Binding Editor.Layers}"
|
||||||
|
SelectedItem="{Binding Editor.ActiveLayer}"
|
||||||
|
SelectionMode="Single">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<CheckBox IsChecked="{Binding IsVisible}" MinWidth="0"/>
|
||||||
|
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
using System;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Minint.Controls;
|
||||||
|
using Minint.ViewModels;
|
||||||
|
|
||||||
namespace Minint.Views;
|
namespace Minint.Views;
|
||||||
|
|
||||||
@@ -8,4 +12,23 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
protected override void OnOpened(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnOpened(e);
|
||||||
|
|
||||||
|
var canvas = this.FindControl<PixelCanvas>("Canvas");
|
||||||
|
var hScroll = this.FindControl<ScrollBar>("HScroll");
|
||||||
|
var vScroll = this.FindControl<ScrollBar>("VScroll");
|
||||||
|
|
||||||
|
if (canvas is not null && hScroll is not null && vScroll is not null)
|
||||||
|
canvas.AttachScrollBars(hScroll, vScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is MainWindowViewModel vm)
|
||||||
|
vm.Owner = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user