Этап 6

This commit is contained in:
2026-03-29 16:32:04 +03:00
parent 400af7982e
commit 9ef5ad8f68
5 changed files with 437 additions and 23 deletions

View File

@@ -1,59 +1,325 @@
using System; using System;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Threading;
namespace Minint.Controls; namespace Minint.Controls;
/// <summary> /// <summary>
/// Custom control that renders a WriteableBitmap with nearest-neighbor interpolation. /// Custom control that renders a WriteableBitmap through a Viewport (pan/zoom).
/// Pan/zoom will be added in Stage 6. /// 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> /// </summary>
public class PixelCanvas : Control public class PixelCanvas : Control
{ {
#region Styled Properties
public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty = public static readonly StyledProperty<WriteableBitmap?> SourceBitmapProperty =
AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap)); AvaloniaProperty.Register<PixelCanvas, WriteableBitmap?>(nameof(SourceBitmap));
public static readonly StyledProperty<bool> ShowGridProperty =
AvaloniaProperty.Register<PixelCanvas, bool>(nameof(ShowGrid), defaultValue: false);
public WriteableBitmap? SourceBitmap public WriteableBitmap? SourceBitmap
{ {
get => GetValue(SourceBitmapProperty); get => GetValue(SourceBitmapProperty);
set => SetValue(SourceBitmapProperty, value); 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() static PixelCanvas()
{ {
AffectsRender<PixelCanvas>(SourceBitmapProperty); 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) public override void Render(DrawingContext context)
{ {
base.Render(context); base.Render(context);
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
var bmp = SourceBitmap; var bmp = SourceBitmap;
if (bmp is null) if (bmp is null)
return; return;
var srcSize = bmp.PixelSize; int imgW = bmp.PixelSize.Width;
var bounds = Bounds; int imgH = bmp.PixelSize.Height;
// Fit image into control bounds preserving aspect ratio, centered if (!_viewportInitialized)
double scaleX = bounds.Width / srcSize.Width; {
double scaleY = bounds.Height / srcSize.Height; _viewport.FitToView(imgW, imgH, Bounds.Width, Bounds.Height);
double scale = Math.Min(scaleX, scaleY); _viewportInitialized = true;
if (scale < 1) scale = Math.Max(1, Math.Floor(scale)); }
else scale = Math.Max(1, Math.Floor(scale));
double dstW = srcSize.Width * scale; DrawCheckerboard(context, imgW, imgH);
double dstH = srcSize.Height * scale;
double offsetX = (bounds.Width - dstW) / 2;
double offsetY = (bounds.Height - dstH) / 2;
var destRect = new Rect(offsetX, offsetY, dstW, dstH); var destRect = _viewport.ImageScreenRect(imgW, imgH);
var srcRect = new Rect(0, 0, srcSize.Width, srcSize.Height); var srcRect = new Rect(0, 0, imgW, imgH);
// Nearest-neighbor for pixel-perfect rendering
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)
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
View File

@@ -0,0 +1,119 @@
using System;
using Avalonia;
namespace Minint.Controls;
/// <summary>
/// Manages zoom level and pan offset for the pixel canvas.
/// Provides screen↔pixel coordinate transforms.
/// </summary>
public sealed class Viewport
{
public double Zoom { get; set; } = 1.0;
public double OffsetX { get; set; }
public double OffsetY { get; set; }
public const double MinZoom = 0.25;
public const double MaxZoom = 128.0;
/// <summary>
/// Zoom base per 1.0 unit of wheel delta. Actual factor = Pow(base, |delta|).
/// Touchpad nudge (delta ~0.1) → ~1.01×, mouse tick (delta 1.0) → 1.10×, fast (3.0) → 1.33×.
/// </summary>
private const double ZoomBase = 1.10;
public (int X, int Y) ScreenToPixel(double screenX, double screenY) =>
((int)Math.Floor((screenX - OffsetX) / Zoom),
(int)Math.Floor((screenY - OffsetY) / Zoom));
public (double X, double Y) PixelToScreen(int pixelX, int pixelY) =>
(pixelX * Zoom + OffsetX,
pixelY * Zoom + OffsetY);
public Rect ImageScreenRect(int imageWidth, int imageHeight) =>
new(OffsetX, OffsetY, imageWidth * Zoom, imageHeight * Zoom);
/// <summary>
/// Zooms keeping the point under cursor fixed.
/// Uses the actual magnitude of <paramref name="delta"/> for proportional zoom.
/// </summary>
public void ZoomAtPoint(double screenX, double screenY, double delta,
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
double absDelta = Math.Abs(delta);
double factor = delta > 0 ? Math.Pow(ZoomBase, absDelta) : 1.0 / Math.Pow(ZoomBase, absDelta);
double newZoom = Math.Clamp(Zoom * factor, MinZoom, MaxZoom);
if (Math.Abs(newZoom - Zoom) < 1e-12) return;
double pixelX = (screenX - OffsetX) / Zoom;
double pixelY = (screenY - OffsetY) / Zoom;
Zoom = newZoom;
OffsetX = screenX - pixelX * Zoom;
OffsetY = screenY - pixelY * Zoom;
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
}
/// <summary>
/// Pans by screen-space delta, then clamps so the image can't be scrolled out of view.
/// </summary>
public void Pan(double deltaX, double deltaY,
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
OffsetX += deltaX;
OffsetY += deltaY;
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
}
/// <summary>
/// Sets offset directly (e.g. from middle-mouse drag), then clamps.
/// </summary>
public void SetOffset(double offsetX, double offsetY,
int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
OffsetX = offsetX;
OffsetY = offsetY;
ClampOffset(imageWidth, imageHeight, controlWidth, controlHeight);
}
/// <summary>
/// Ensures at least <c>minVisible</c> pixels of the image remain on screen on each edge.
/// </summary>
public void ClampOffset(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
double extentW = imageWidth * Zoom;
double extentH = imageHeight * Zoom;
double minVisH = Math.Max(32, Math.Min(controlWidth, extentW) * 0.10);
double minVisV = Math.Max(32, Math.Min(controlHeight, extentH) * 0.10);
// Image right edge must be >= minVisH from left of control
// Image left edge must be <= controlWidth - minVisH from left
OffsetX = Math.Clamp(OffsetX, minVisH - extentW, controlWidth - minVisH);
OffsetY = Math.Clamp(OffsetY, minVisV - extentH, controlHeight - minVisV);
}
public void FitToView(int imageWidth, int imageHeight, double controlWidth, double controlHeight)
{
if (imageWidth <= 0 || imageHeight <= 0 || controlWidth <= 0 || controlHeight <= 0)
return;
double scaleX = controlWidth / imageWidth;
double scaleY = controlHeight / imageHeight;
Zoom = Math.Max(1.0, Math.Floor(Math.Min(scaleX, scaleY)));
OffsetX = (controlWidth - imageWidth * Zoom) / 2.0;
OffsetY = (controlHeight - imageHeight * Zoom) / 2.0;
}
public (double Min, double Max, double Value, double ViewportSize)
GetScrollInfo(int imageSize, double controlSize, double offset)
{
double extent = imageSize * Zoom;
double minVis = Math.Max(32, Math.Min(controlSize, extent) * 0.10);
double min = minVis - extent;
double max = controlSize - minVis;
double viewportSize = Math.Min(controlSize, extent);
return (min, max, offset, viewportSize);
}
}

View File

@@ -30,6 +30,9 @@ public partial class EditorViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private WriteableBitmap? _canvasBitmap; private WriteableBitmap? _canvasBitmap;
[ObservableProperty]
private bool _showGrid;
/// <summary> /// <summary>
/// Path of the currently open file, or null for unsaved new containers. /// Path of the currently open file, or null for unsaved new containers.
/// </summary> /// </summary>

View File

@@ -25,6 +25,10 @@
<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"/>
</MenuItem> </MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Pixel _Grid" ToggleType="CheckBox"
IsChecked="{Binding Editor.ShowGrid}" HotKey="Ctrl+G"/>
</MenuItem>
</Menu> </Menu>
<!-- Status bar --> <!-- Status bar -->
@@ -53,10 +57,18 @@
</DockPanel> </DockPanel>
</Border> </Border>
<!-- Center: canvas --> <!-- Center: canvas with scrollbars -->
<Border Grid.Column="1" Background="#FF1E1E1E" ClipToBounds="True"> <Grid Grid.Column="1" RowDefinitions="*,Auto" ColumnDefinitions="*,Auto">
<controls:PixelCanvas SourceBitmap="{Binding Editor.CanvasBitmap}"/> <Border Grid.Row="0" Grid.Column="0" Background="#FF1E1E1E" ClipToBounds="True">
</Border> <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 --> <!-- Right panel: layers -->
<Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" <Border Grid.Column="2" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"

View File

@@ -1,5 +1,7 @@
using System; using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Minint.Controls;
using Minint.ViewModels; using Minint.ViewModels;
namespace Minint.Views; namespace Minint.Views;
@@ -11,6 +13,18 @@ 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) protected override void OnDataContextChanged(EventArgs e)
{ {
base.OnDataContextChanged(e); base.OnDataContextChanged(e);