From 9ef5ad8f6871bfbca76ae0a9582a4f299d98de6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 29 Mar 2026 16:32:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Minint/Controls/PixelCanvas.cs | 304 +++++++++++++++++++++++++-- Minint/Controls/Viewport.cs | 119 +++++++++++ Minint/ViewModels/EditorViewModel.cs | 3 + Minint/Views/MainWindow.axaml | 20 +- Minint/Views/MainWindow.axaml.cs | 14 ++ 5 files changed, 437 insertions(+), 23 deletions(-) create mode 100644 Minint/Controls/Viewport.cs 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);