diff --git a/Minint/Controls/PixelCanvas.cs b/Minint/Controls/PixelCanvas.cs
index c1fc1ab..c60ed6c 100644
--- a/Minint/Controls/PixelCanvas.cs
+++ b/Minint/Controls/PixelCanvas.cs
@@ -1,59 +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;
///
-/// Custom control that renders a WriteableBitmap with nearest-neighbor interpolation.
-/// Pan/zoom will be added in Stage 6.
+/// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom).
+/// Supports nearest-neighbor scaling, pixel grid overlay, scrollbars, and mouse interaction.
+/// Input model:
+///
+/// - Ctrl+Wheel: zoom at cursor
+/// - Wheel: scroll vertically
+/// - Shift+Wheel: scroll horizontally
+/// - Touchpad two-finger scroll: free pan (Delta.X + Delta.Y)
+/// - Middle mouse drag: pan
+///
///
public class PixelCanvas : Control
{
+ #region Styled Properties
+
public static readonly StyledProperty SourceBitmapProperty =
AvaloniaProperty.Register(nameof(SourceBitmap));
+ public static readonly StyledProperty ShowGridProperty =
+ AvaloniaProperty.Register(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(SourceBitmapProperty);
+ AffectsRender(SourceBitmapProperty, ShowGridProperty);
+ FocusableProperty.OverrideDefaultValue(true);
}
+ public PixelCanvas()
+ {
+ ClipToBounds = true;
+ }
+
+ ///
+ /// Connects external ScrollBar controls. Call once after the UI is built.
+ ///
+ 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;
- var srcSize = bmp.PixelSize;
- var bounds = Bounds;
+ int imgW = bmp.PixelSize.Width;
+ int imgH = bmp.PixelSize.Height;
- // Fit image into control bounds preserving aspect ratio, centered
- double scaleX = bounds.Width / srcSize.Width;
- double scaleY = bounds.Height / srcSize.Height;
- double scale = Math.Min(scaleX, scaleY);
- if (scale < 1) scale = Math.Max(1, Math.Floor(scale));
- else scale = Math.Max(1, Math.Floor(scale));
+ if (!_viewportInitialized)
+ {
+ _viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
+ _viewportInitialized = true;
+ }
- double dstW = srcSize.Width * scale;
- double dstH = srcSize.Height * scale;
- double offsetX = (bounds.Width - dstW) / 2;
- double offsetY = (bounds.Height - dstH) / 2;
+ DrawCheckerboard(context, imgW, imgH);
- var destRect = new Rect(offsetX, offsetY, dstW, dstH);
- var srcRect = new Rect(0, 0, srcSize.Width, srcSize.Height);
-
- // Nearest-neighbor for pixel-perfect rendering
+ 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;
}
}
diff --git a/Minint/Controls/Viewport.cs b/Minint/Controls/Viewport.cs
new file mode 100644
index 0000000..baa3c33
--- /dev/null
+++ b/Minint/Controls/Viewport.cs
@@ -0,0 +1,119 @@
+using System;
+using Avalonia;
+
+namespace Minint.Controls;
+
+///
+/// Manages zoom level and pan offset for the pixel canvas.
+/// Provides screen↔pixel coordinate transforms.
+///
+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;
+
+ ///
+ /// 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×.
+ ///
+ 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);
+
+ ///
+ /// Zooms keeping the point under cursor fixed.
+ /// Uses the actual magnitude of for proportional zoom.
+ ///
+ 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);
+ }
+
+ ///
+ /// Pans by screen-space delta, then clamps so the image can't be scrolled out of view.
+ ///
+ public void Pan(double deltaX, double deltaY,
+ int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ OffsetX += deltaX;
+ OffsetY += deltaY;
+ ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
+ }
+
+ ///
+ /// Sets offset directly (e.g. from middle-mouse drag), then clamps.
+ ///
+ public void SetOffset(double offsetX, double offsetY,
+ int imageWidth, int imageHeight, double controlWidth, double controlHeight)
+ {
+ OffsetX = offsetX;
+ OffsetY = offsetY;
+ ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
+ }
+
+ ///
+ /// Ensures at least minVisible pixels of the image remain on screen on each edge.
+ ///
+ 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);
+ }
+}
diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs
index 18e9146..100306f 100644
--- a/Minint/ViewModels/EditorViewModel.cs
+++ b/Minint/ViewModels/EditorViewModel.cs
@@ -30,6 +30,9 @@ public partial class EditorViewModel : ViewModelBase
[ObservableProperty]
private WriteableBitmap? _canvasBitmap;
+ [ObservableProperty]
+ private bool _showGrid;
+
///
/// Path of the currently open file, or null for unsaved new containers.
///
diff --git a/Minint/Views/MainWindow.axaml b/Minint/Views/MainWindow.axaml
index 261aed7..1c3474c 100644
--- a/Minint/Views/MainWindow.axaml
+++ b/Minint/Views/MainWindow.axaml
@@ -25,6 +25,10 @@
+
@@ -53,10 +57,18 @@
-
-
-
-
+
+
+
+
+
+
+
+
("Canvas");
+ var hScroll = this.FindControl("HScroll");
+ var vScroll = this.FindControl("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);