From 3a61e0a07d9e51e45134872e52bec44e07302310 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 17:25:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Minint.Core/Services/IImageEffectsService.cs | 18 +++ .../Services/Impl/ImageEffectsService.cs | 38 ++++++ Minint.Core/Services/Impl/PatternGenerator.cs | 113 ++++++++++++++++ Minint/Controls/PixelCanvas.cs | 36 +++++- Minint/Program.cs | 3 +- Minint/ViewModels/EditorViewModel.cs | 6 + Minint/ViewModels/MainWindowViewModel.cs | 122 +++++++++++++++++- Minint/Views/ContrastDialog.axaml | 20 +++ Minint/Views/ContrastDialog.axaml.cs | 24 ++++ Minint/Views/CopyFragmentDialog.axaml | 51 ++++++++ Minint/Views/CopyFragmentDialog.axaml.cs | 91 +++++++++++++ Minint/Views/MainWindow.axaml | 34 +++-- Minint/Views/PatternDialog.axaml | 36 ++++++ Minint/Views/PatternDialog.axaml.cs | 50 +++++++ 14 files changed, 622 insertions(+), 20 deletions(-) create mode 100644 Minint.Core/Services/IImageEffectsService.cs create mode 100644 Minint.Core/Services/Impl/ImageEffectsService.cs create mode 100644 Minint.Core/Services/Impl/PatternGenerator.cs create mode 100644 Minint/Views/ContrastDialog.axaml create mode 100644 Minint/Views/ContrastDialog.axaml.cs create mode 100644 Minint/Views/CopyFragmentDialog.axaml create mode 100644 Minint/Views/CopyFragmentDialog.axaml.cs create mode 100644 Minint/Views/PatternDialog.axaml create mode 100644 Minint/Views/PatternDialog.axaml.cs diff --git a/Minint.Core/Services/IImageEffectsService.cs b/Minint.Core/Services/IImageEffectsService.cs new file mode 100644 index 0000000..570c6c2 --- /dev/null +++ b/Minint.Core/Services/IImageEffectsService.cs @@ -0,0 +1,18 @@ +using Minint.Core.Models; + +namespace Minint.Core.Services; + +public interface IImageEffectsService +{ + /// + /// Adjusts contrast of the document by transforming its palette colors. + /// of 0 = all gray, 1 = no change, >1 = increased contrast. + /// + void ApplyContrast(MinintDocument doc, double factor); + + /// + /// Converts the document to grayscale by transforming its palette colors + /// using the luminance formula: 0.299R + 0.587G + 0.114B. + /// + void ApplyGrayscale(MinintDocument doc); +} diff --git a/Minint.Core/Services/Impl/ImageEffectsService.cs b/Minint.Core/Services/Impl/ImageEffectsService.cs new file mode 100644 index 0000000..67c25f6 --- /dev/null +++ b/Minint.Core/Services/Impl/ImageEffectsService.cs @@ -0,0 +1,38 @@ +using System; +using Minint.Core.Models; + +namespace Minint.Core.Services.Impl; + +public sealed class ImageEffectsService : IImageEffectsService +{ + public void ApplyContrast(MinintDocument doc, double factor) + { + for (int i = 1; i < doc.Palette.Count; i++) + { + var c = doc.Palette[i]; + doc.Palette[i] = new RgbaColor( + ContrastByte(c.R, factor), + ContrastByte(c.G, factor), + ContrastByte(c.B, factor), + c.A); + } + doc.InvalidatePaletteCache(); + } + + public void ApplyGrayscale(MinintDocument doc) + { + for (int i = 1; i < doc.Palette.Count; i++) + { + var c = doc.Palette[i]; + byte gray = (byte)Math.Clamp((int)(0.299 * c.R + 0.587 * c.G + 0.114 * c.B + 0.5), 0, 255); + doc.Palette[i] = new RgbaColor(gray, gray, gray, c.A); + } + doc.InvalidatePaletteCache(); + } + + private static byte ContrastByte(byte value, double factor) + { + double v = ((value / 255.0) - 0.5) * factor + 0.5; + return (byte)Math.Clamp((int)(v * 255 + 0.5), 0, 255); + } +} diff --git a/Minint.Core/Services/Impl/PatternGenerator.cs b/Minint.Core/Services/Impl/PatternGenerator.cs new file mode 100644 index 0000000..8561b47 --- /dev/null +++ b/Minint.Core/Services/Impl/PatternGenerator.cs @@ -0,0 +1,113 @@ +using System; +using Minint.Core.Models; + +namespace Minint.Core.Services.Impl; + +public sealed class PatternGenerator : IPatternGenerator +{ + public MinintDocument Generate(PatternType type, int width, int height, RgbaColor[] colors, int param1, int param2 = 0) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + if (colors.Length < 2) + throw new ArgumentException("At least two colors are required.", nameof(colors)); + + var doc = new MinintDocument($"Pattern ({type})"); + var layer = new MinintLayer("Pattern", width * height); + doc.Layers.Add(layer); + + int[] colorIndices = new int[colors.Length]; + for (int i = 0; i < colors.Length; i++) + colorIndices[i] = doc.EnsureColorCached(colors[i]); + + int cellSize = Math.Max(param1, 1); + + switch (type) + { + case PatternType.Checkerboard: + FillCheckerboard(layer.Pixels, width, height, colorIndices, cellSize); + break; + case PatternType.HorizontalGradient: + FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: true); + break; + case PatternType.VerticalGradient: + FillGradient(layer.Pixels, width, height, colors[0], colors[1], doc, horizontal: false); + break; + case PatternType.HorizontalStripes: + FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: true); + break; + case PatternType.VerticalStripes: + FillStripes(layer.Pixels, width, height, colorIndices, cellSize, horizontal: false); + break; + case PatternType.ConcentricCircles: + FillCircles(layer.Pixels, width, height, colorIndices, cellSize); + break; + case PatternType.Tile: + FillTile(layer.Pixels, width, height, colorIndices, cellSize, Math.Max(param2, 1)); + break; + } + + return doc; + } + + private static void FillCheckerboard(int[] pixels, int w, int h, int[] ci, int cell) + { + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + pixels[y * w + x] = ci[((x / cell) + (y / cell)) % 2 == 0 ? 0 : 1]; + } + + private static void FillGradient(int[] pixels, int w, int h, RgbaColor c0, RgbaColor c1, + MinintDocument doc, bool horizontal) + { + int steps = horizontal ? w : h; + for (int s = 0; s < steps; s++) + { + double t = steps > 1 ? (double)s / (steps - 1) : 0; + var c = new RgbaColor( + Lerp(c0.R, c1.R, t), Lerp(c0.G, c1.G, t), + Lerp(c0.B, c1.B, t), Lerp(c0.A, c1.A, t)); + int idx = doc.EnsureColorCached(c); + if (horizontal) + for (int y = 0; y < h; y++) pixels[y * w + s] = idx; + else + for (int x = 0; x < w; x++) pixels[s * w + x] = idx; + } + } + + private static void FillStripes(int[] pixels, int w, int h, int[] ci, int stripe, bool horizontal) + { + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int coord = horizontal ? y : x; + pixels[y * w + x] = ci[(coord / stripe) % ci.Length]; + } + } + + private static void FillCircles(int[] pixels, int w, int h, int[] ci, int ringWidth) + { + double cx = (w - 1) / 2.0, cy = (h - 1) / 2.0; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + double dist = Math.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)); + int ring = (int)(dist / ringWidth); + pixels[y * w + x] = ci[ring % ci.Length]; + } + } + + private static void FillTile(int[] pixels, int w, int h, int[] ci, int tileW, int tileH) + { + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int tx = (x / tileW) % ci.Length; + int ty = (y / tileH) % ci.Length; + pixels[y * w + x] = ci[(tx + ty) % ci.Length]; + } + } + + private static byte Lerp(byte a, byte b, double t) + => (byte)Math.Clamp((int)(a + (b - a) * t + 0.5), 0, 255); +} diff --git a/Minint/Controls/PixelCanvas.cs b/Minint/Controls/PixelCanvas.cs index 9023710..9ca6b70 100644 --- a/Minint/Controls/PixelCanvas.cs +++ b/Minint/Controls/PixelCanvas.cs @@ -56,7 +56,10 @@ public class PixelCanvas : Control private Point _panStart; private double _panStartOffsetX, _panStartOffsetY; private bool _viewportInitialized; + private int _lastBitmapWidth; + private int _lastBitmapHeight; private (int X, int Y)? _lastCursorPixel; + private Point? _lastScreenPos; private ScrollBar? _hScrollBar; private ScrollBar? _vScrollBar; @@ -248,6 +251,7 @@ public class PixelCanvas : Control if (_suppressScrollSync) return; var (imgW, imgH) = GetImageSize(); _viewport.SetOffset(-e.NewValue, _viewport.OffsetY, imgW, imgH, Bounds.Width, Bounds.Height); + RecalcCursorPixel(); InvalidateVisual(); } @@ -256,6 +260,7 @@ public class PixelCanvas : Control if (_suppressScrollSync) return; var (imgW, imgH) = GetImageSize(); _viewport.SetOffset(_viewport.OffsetX, -e.NewValue, imgW, imgH, Bounds.Width, Bounds.Height); + RecalcCursorPixel(); InvalidateVisual(); } @@ -279,6 +284,20 @@ public class PixelCanvas : Control return (px, py); } + /// + /// Recalculates the pixel coordinate under the cursor after a viewport change. + /// + private void RecalcCursorPixel() + { + if (_lastScreenPos is null) return; + var pixel = ScreenToPixelClamped(_lastScreenPos.Value); + if (pixel != _lastCursorPixel) + { + _lastCursorPixel = pixel; + CursorPixelChanged?.Invoke(pixel); + } + } + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); @@ -305,6 +324,7 @@ public class PixelCanvas : Control _viewport.Pan(dx, dy, imgW, imgH, Bounds.Width, Bounds.Height); } + RecalcCursorPixel(); InvalidateVisual(); e.Handled = true; } @@ -338,6 +358,7 @@ public class PixelCanvas : Control { base.OnPointerMoved(e); var pos = e.GetPosition(this); + _lastScreenPos = pos; if (_isPanning) { @@ -346,12 +367,12 @@ public class PixelCanvas : Control _panStartOffsetX + (pos.X - _panStart.X), _panStartOffsetY + (pos.Y - _panStart.Y), imgW, imgH, Bounds.Width, Bounds.Height); + RecalcCursorPixel(); InvalidateVisual(); e.Handled = true; return; } - // Update preview cursor position var pixel = ScreenToPixelClamped(pos); if (pixel != _lastCursorPixel) { @@ -386,6 +407,7 @@ public class PixelCanvas : Control protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); + _lastScreenPos = null; if (_lastCursorPixel is not null) { _lastCursorPixel = null; @@ -400,6 +422,16 @@ public class PixelCanvas : Control { base.OnPropertyChanged(change); if (change.Property == SourceBitmapProperty) - _viewportInitialized = false; + { + var bmp = change.GetNewValue(); + int w = bmp?.PixelSize.Width ?? 0; + int h = bmp?.PixelSize.Height ?? 0; + if (w != _lastBitmapWidth || h != _lastBitmapHeight) + { + _lastBitmapWidth = w; + _lastBitmapHeight = h; + _viewportInitialized = false; + } + } } } diff --git a/Minint/Program.cs b/Minint/Program.cs index f061ea8..26aeca2 100644 --- a/Minint/Program.cs +++ b/Minint/Program.cs @@ -28,7 +28,8 @@ sealed class Program => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() - .LogToTrace(); + .LogToTrace() + .With(new X11PlatformOptions { OverlayPopups = true }); // TODO: temporary tests — remove after verification stages. diff --git a/Minint/ViewModels/EditorViewModel.cs b/Minint/ViewModels/EditorViewModel.cs index dd21276..42c91be 100644 --- a/Minint/ViewModels/EditorViewModel.cs +++ b/Minint/ViewModels/EditorViewModel.cs @@ -113,6 +113,12 @@ public partial class EditorViewModel : ViewModelBase SyncLayersAndCanvas(doc); } + /// + /// Re-syncs the Documents observable collection after an external modification + /// to Container.Documents (e.g. pattern generation adding a document). + /// + public void SyncAfterExternalChange() => SyncDocumentsList(); + private void SyncDocumentsList() { Documents.Clear(); diff --git a/Minint/ViewModels/MainWindowViewModel.cs b/Minint/ViewModels/MainWindowViewModel.cs index 887c256..15fed79 100644 --- a/Minint/ViewModels/MainWindowViewModel.cs +++ b/Minint/ViewModels/MainWindowViewModel.cs @@ -6,13 +6,19 @@ using Avalonia.Controls; using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Minint.Core.Models; +using Minint.Core.Services; +using Minint.Core.Services.Impl; using Minint.Infrastructure.Serialization; +using Minint.Views; namespace Minint.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private readonly MinintSerializer _serializer = new(); + private readonly IImageEffectsService _effects = new ImageEffectsService(); + private readonly IPatternGenerator _patternGen = new PatternGenerator(); private static readonly FilePickerFileType MinintFileType = new("Minint Files") { @@ -25,11 +31,10 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string _statusText = "Ready"; - /// - /// Set by the view so that file dialogs can use the correct parent window. - /// public TopLevel? Owner { get; set; } + #region File commands + [RelayCommand] private void NewFile() { @@ -72,13 +77,9 @@ public partial class MainWindowViewModel : ViewModelBase if (Editor.Container is null) return; if (Editor.FilePath is not null) - { await SaveToPathAsync(Editor.FilePath); - } else - { await SaveFileAsAsync(); - } } [RelayCommand] @@ -121,4 +122,111 @@ public partial class MainWindowViewModel : ViewModelBase StatusText = $"Error saving file: {ex.Message}"; } } + + #endregion + + #region Effects (A1, A2) + + [RelayCommand] + private async Task ApplyContrastAsync() + { + if (Editor.ActiveDocument is null || Owner is not Window window) return; + + var dialog = new ContrastDialog(); + var result = await dialog.ShowDialog(window); + if (result != true) return; + + _effects.ApplyContrast(Editor.ActiveDocument, dialog.Factor); + Editor.RefreshCanvas(); + StatusText = $"Contrast ×{dialog.Factor:F1} applied."; + } + + [RelayCommand] + private void ApplyGrayscale() + { + if (Editor.ActiveDocument is null) return; + + _effects.ApplyGrayscale(Editor.ActiveDocument); + Editor.RefreshCanvas(); + StatusText = "Grayscale applied."; + } + + #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(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) + + [RelayCommand] + private async Task GeneratePatternAsync() + { + if (Editor.Container is null || Owner is not Window window) return; + + var dialog = new PatternDialog(); + var result = await dialog.ShowDialog(window); + if (result != true) return; + + try + { + var doc = _patternGen.Generate( + dialog.SelectedPattern, + Editor.Container.Width, + Editor.Container.Height, + [dialog.PatternColor1, dialog.PatternColor2], + dialog.PatternParam1, + dialog.PatternParam2); + + Editor.Container.Documents.Add(doc); + Editor.SyncAfterExternalChange(); + Editor.SelectDocument(doc); + StatusText = $"Pattern '{dialog.SelectedPattern}' generated."; + } + catch (Exception ex) + { + StatusText = $"Pattern generation failed: {ex.Message}"; + } + } + + #endregion } diff --git a/Minint/Views/ContrastDialog.axaml b/Minint/Views/ContrastDialog.axaml new file mode 100644 index 0000000..36b64b2 --- /dev/null +++ b/Minint/Views/ContrastDialog.axaml @@ -0,0 +1,20 @@ + + + + + + + +