From 39ae4a5c9950ff0595cdd1ab591fc64df2bd528b Mon Sep 17 00:00:00 2001 From: Jeff Holcombe Date: Sat, 13 Dec 2025 20:25:43 -0500 Subject: [PATCH 1/6] Zoom. Trace. +Brushes. Duplicate (#29) * syntax cleanup namespace usings * duplicate element. copy paste * +brushes. needs refinement * give it the figner. finally * zoom controls * transparent net10 attempt * cleanup * working transparency * +comments * trace mode vm update. clean-up * Dynamic transparent slider max disabled * test corrections * storage for flag. disable by default * pref save. +tests --- .vscode/launch.json | 7 + AppShell.xaml | 3 +- Components/BrushPreviewControl.cs | 217 ++- Components/BrushesFlyoutPanel.xaml.cs | 23 +- Components/FlyoutPanel.xaml.cs | 427 +++--- Components/LayerControlView.xaml | 304 +++-- Components/LayerControlView.xaml.cs | 113 +- Components/MiniMapView.xaml.cs | 291 ++-- Components/SettingsFlyoutPanel.xaml.cs | 591 ++++---- Components/ShapePreviewControl.cs | 169 ++- Components/ShapesFlyoutPanel.xaml.cs | 19 +- Converters/BoolToEyeIconConverter.cs | 35 +- Converters/BoolToLayerPanelWidthConverter.cs | 29 +- Converters/BoolToLockIconConverter.cs | 35 +- Converters/ColorToHexConverter.cs | 77 +- Converters/ToolNameToIconConverter.cs | 61 +- Converters/ToolToColorConverter.cs | 2 +- Documentation/TransparentBackgroundExample.md | 160 +++ Logic/Config/FeatureFlags.cs | 6 + Logic/Extensions/SkiaSharpExtensions.cs | 265 ++-- Logic/Handlers/CanvasInputHandler.cs | 526 +++---- Logic/Handlers/ICanvasInputHandler.cs | 9 +- Logic/Messages/BrushSettingsChangedMessage.cs | 77 +- Logic/Messages/BrushShapeChangedMessage.cs | 13 +- Logic/Messages/CanvasInvalidateMessage.cs | 19 +- Logic/Messages/DrawingStateChangedMessage.cs | 17 +- Logic/Messages/ElementAddedMessage.cs | 21 +- Logic/Messages/ElementRemovedMessage.cs | 21 +- Logic/Messages/LayerChangedMessage.cs | 19 +- Logic/Messages/SelectionChangedMessage.cs | 19 +- Logic/Messages/ToolChangedMessage.cs | 19 +- Logic/Models/BrushShape.cs | 554 +++++--- Logic/Models/DrawableEllipse.cs | 301 ++-- Logic/Models/DrawableGroup.cs | 239 ++-- Logic/Models/DrawableImage.cs | 319 +++-- Logic/Models/DrawableLine.cs | 279 ++-- Logic/Models/DrawablePath.cs | 347 +++-- Logic/Models/DrawableRectangle.cs | 307 +++-- Logic/Models/DrawableStamps.cs | 1153 ++++++++-------- Logic/Models/IDrawableElement.cs | 209 ++- Logic/Models/Layer.cs | 203 ++- Logic/Models/MaskingMode.cs | 17 +- Logic/Models/NavigationModel.cs | 57 +- Logic/Models/ToolContext.cs | 57 +- Logic/Services/IPreferencesService.cs | 7 + Logic/Services/PreferencesService.cs | 9 + Logic/Tools/EllipseTool.cs | 45 +- Logic/Tools/EraserBrushTool.cs | 523 ++++--- Logic/Tools/EraserTool.cs | 82 +- Logic/Tools/FillTool.cs | 62 +- Logic/Tools/FreehandTool.cs | 352 +++-- Logic/Tools/IDrawingTool.cs | 44 +- Logic/Tools/LineTool.cs | 96 +- Logic/Tools/RectangleTool.cs | 45 +- Logic/Tools/SelectTool.cs | 422 +++--- Logic/Tools/ShapeTool.cs | 94 +- Logic/Utils/BitmapCache.cs | 277 ++-- Logic/Utils/ClipboardMemento.cs | 29 +- Logic/Utils/HistoryMemento.cs | 89 +- Logic/Utils/ILayerFacade.cs | 27 +- Logic/Utils/LayerFacade.cs | 169 ++- Logic/Utils/QuadTree.cs | 173 --- Logic/Utils/QuadTreeMemento.cs | 172 +++ Logic/Utils/SelectionObserver.cs | 161 ++- Logic/ViewModels/HistoryViewModel.cs | 109 +- Logic/ViewModels/LayerPanelViewModel.cs | 239 ++-- Logic/ViewModels/MainViewModel.cs | 200 +-- Logic/ViewModels/SelectionViewModel.cs | 421 +++--- Logic/ViewModels/ToolbarViewModel.cs | 922 ++++++------- LunaDraw.csproj | 16 +- MauiProgram.cs | 28 +- Pages/MainPage.xaml | 49 +- Pages/MainPage.xaml.cs | 17 +- Platforms/MacCatalyst/Program.cs | 1 - Platforms/Windows/App.xaml.cs | 23 +- Platforms/Windows/CompositionBrush.cs.cs | 67 + Platforms/Windows/PlatformHelper.cs.cs | 598 ++++++++ Platforms/Windows/TransparentBackdrop.cs.cs | 40 + Platforms/iOS/Program.cs | 1 - Resources/Styles/Colors.xaml | 96 +- Resources/Styles/Styles.xaml | 1216 +++++++++-------- .../CanvasInputHandlerRobustTests.cs | 2 +- .../LunaDraw.Tests/CanvasInputHandlerTests.cs | 27 +- .../EraserBrushToolStampsTests.cs | 25 +- tests/LunaDraw.Tests/EraserBrushToolTests.cs | 2 +- tests/LunaDraw.Tests/EraserToolTests.cs | 2 +- tests/LunaDraw.Tests/FillToolTests.cs | 4 +- tests/LunaDraw.Tests/FreehandToolTests.cs | 4 +- tests/LunaDraw.Tests/HistoryManagerTests.cs | 4 +- .../LayerPanelViewModelTests.cs | 41 +- .../LunaDraw.Tests/LayerStateManagerTests.cs | 4 +- tests/LunaDraw.Tests/MainViewModelTests.cs | 10 +- tests/LunaDraw.Tests/NoFillTests.cs | 2 +- .../SelectToolInteractionTests.cs | 2 +- tests/LunaDraw.Tests/SelectToolTests.cs | 2 +- tests/LunaDraw.Tests/SelectionManagerTests.cs | 4 +- .../LunaDraw.Tests/SelectionViewModelTests.cs | 30 +- tests/LunaDraw.Tests/ShapeToolsTests.cs | 4 +- tests/LunaDraw.Tests/ToolbarViewModelTests.cs | 2 +- 99 files changed, 8086 insertions(+), 6642 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 Documentation/TransparentBackgroundExample.md create mode 100644 Logic/Config/FeatureFlags.cs create mode 100644 Logic/Services/IPreferencesService.cs create mode 100644 Logic/Services/PreferencesService.cs delete mode 100644 Logic/Utils/QuadTree.cs create mode 100644 Logic/Utils/QuadTreeMemento.cs create mode 100644 Platforms/Windows/CompositionBrush.cs.cs create mode 100644 Platforms/Windows/PlatformHelper.cs.cs create mode 100644 Platforms/Windows/TransparentBackdrop.cs.cs diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..787b44f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,7 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [] +} \ No newline at end of file diff --git a/AppShell.xaml b/AppShell.xaml index e290a30..e973e1a 100644 --- a/AppShell.xaml +++ b/AppShell.xaml @@ -4,7 +4,8 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:pages="clr-namespace:LunaDraw.Pages" - Title="LunaDraw"> + Title="LunaDraw" + Shell.BackgroundColor="Transparent"> (BrushShape)GetValue(BrushShapeProperty); + set => SetValue(BrushShapeProperty, value); + } - public BrushShape BrushShape - { - get => (BrushShape)GetValue(BrushShapeProperty); - set => SetValue(BrushShapeProperty, value); - } + public static readonly BindableProperty ColorProperty = + BindableProperty.Create(nameof(Color), typeof(Color), typeof(BrushPreviewControl), Colors.Black, propertyChanged: OnPropertyChanged); - public static readonly BindableProperty ColorProperty = - BindableProperty.Create(nameof(Color), typeof(Color), typeof(BrushPreviewControl), Colors.Black, propertyChanged: OnPropertyChanged); + public Color Color + { + get => (Color)GetValue(ColorProperty); + set => SetValue(ColorProperty, value); + } - public Color Color - { - get => (Color)GetValue(ColorProperty); - set => SetValue(ColorProperty, value); - } + public static readonly BindableProperty StrokeColorProperty = + BindableProperty.Create(nameof(StrokeColor), typeof(SKColor), typeof(BrushPreviewControl), SKColors.Empty, propertyChanged: OnPropertyChanged); - public static readonly BindableProperty StrokeColorProperty = - BindableProperty.Create(nameof(StrokeColor), typeof(SKColor), typeof(BrushPreviewControl), SKColors.Empty, propertyChanged: OnPropertyChanged); + public SKColor StrokeColor + { + get => (SKColor)GetValue(StrokeColorProperty); + set => SetValue(StrokeColorProperty, value); + } - public SKColor StrokeColor - { - get => (SKColor)GetValue(StrokeColorProperty); - set => SetValue(StrokeColorProperty, value); - } + public static readonly BindableProperty FillColorProperty = + BindableProperty.Create(nameof(FillColor), typeof(SKColor?), typeof(BrushPreviewControl), null, propertyChanged: OnPropertyChanged); - public static readonly BindableProperty FillColorProperty = - BindableProperty.Create(nameof(FillColor), typeof(SKColor?), typeof(BrushPreviewControl), null, propertyChanged: OnPropertyChanged); + public SKColor? FillColor + { + get => (SKColor?)GetValue(FillColorProperty); + set => SetValue(FillColorProperty, value); + } - public SKColor? FillColor - { - get => (SKColor?)GetValue(FillColorProperty); - set => SetValue(FillColorProperty, value); - } + public static readonly BindableProperty IsGlowEnabledProperty = + BindableProperty.Create(nameof(IsGlowEnabled), typeof(bool), typeof(BrushPreviewControl), false, propertyChanged: OnPropertyChanged); - public static readonly BindableProperty IsGlowEnabledProperty = - BindableProperty.Create(nameof(IsGlowEnabled), typeof(bool), typeof(BrushPreviewControl), false, propertyChanged: OnPropertyChanged); + public bool IsGlowEnabled + { + get => (bool)GetValue(IsGlowEnabledProperty); + set => SetValue(IsGlowEnabledProperty, value); + } - public bool IsGlowEnabled - { - get => (bool)GetValue(IsGlowEnabledProperty); - set => SetValue(IsGlowEnabledProperty, value); - } + public static readonly BindableProperty GlowColorProperty = + BindableProperty.Create(nameof(GlowColor), typeof(SKColor), typeof(BrushPreviewControl), SKColors.Yellow, propertyChanged: OnPropertyChanged); - public static readonly BindableProperty GlowColorProperty = - BindableProperty.Create(nameof(GlowColor), typeof(SKColor), typeof(BrushPreviewControl), SKColors.Yellow, propertyChanged: OnPropertyChanged); + public SKColor GlowColor + { + get => (SKColor)GetValue(GlowColorProperty); + set => SetValue(GlowColorProperty, value); + } - public SKColor GlowColor - { - get => (SKColor)GetValue(GlowColorProperty); - set => SetValue(GlowColorProperty, value); - } + public static readonly BindableProperty GlowRadiusProperty = + BindableProperty.Create(nameof(GlowRadius), typeof(float), typeof(BrushPreviewControl), 10f, propertyChanged: OnPropertyChanged); - public static readonly BindableProperty GlowRadiusProperty = - BindableProperty.Create(nameof(GlowRadius), typeof(float), typeof(BrushPreviewControl), 10f, propertyChanged: OnPropertyChanged); + public float GlowRadius + { + get => (float)GetValue(GlowRadiusProperty); + set => SetValue(GlowRadiusProperty, value); + } + + private static void OnPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + ((BrushPreviewControl)bindable).InvalidateSurface(); + } + + protected override void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + base.OnPaintSurface(e); - public float GlowRadius + var canvas = e.Surface.Canvas; + canvas.Clear(); + + if (BrushShape?.Path == null) return; + + var info = e.Info; + var center = new SKPoint(info.Width / 2, info.Height / 2); + + // Calculate scale to fit in the view (with padding) + var bounds = BrushShape.Path.TightBounds; + float maxDim = Math.Max(bounds.Width, bounds.Height); + if (maxDim <= 0) maxDim = 1; + + float availableSize = Math.Min(info.Width, info.Height) * 0.6f; // 60% padding + float scale = availableSize / maxDim; + + SKColor drawColor; + if (StrokeColor != SKColors.Empty) { - get => (float)GetValue(GlowRadiusProperty); - set => SetValue(GlowRadiusProperty, value); + drawColor = StrokeColor; } - - private static void OnPropertyChanged(BindableObject bindable, object oldValue, object newValue) + else { - ((BrushPreviewControl)bindable).InvalidateSurface(); + drawColor = new SKColor((byte)(Color.Red * 255), (byte)(Color.Green * 255), (byte)(Color.Blue * 255), (byte)(Color.Alpha * 255)); } - protected override void OnPaintSurface(SKPaintSurfaceEventArgs e) + canvas.Save(); + canvas.Translate(center.X, center.Y); + canvas.Scale(scale); + // Center the shape itself (if not centered at 0,0) + canvas.Translate(-bounds.MidX, -bounds.MidY); + + // Draw Glow if enabled + if (IsGlowEnabled) { - base.OnPaintSurface(e); - - var canvas = e.Surface.Canvas; - canvas.Clear(); - - if (BrushShape?.Path == null) return; - - var info = e.Info; - var center = new SKPoint(info.Width / 2, info.Height / 2); - - // Calculate scale to fit in the view (with padding) - var bounds = BrushShape.Path.TightBounds; - float maxDim = Math.Max(bounds.Width, bounds.Height); - if (maxDim <= 0) maxDim = 1; - - float availableSize = Math.Min(info.Width, info.Height) * 0.6f; // 60% padding - float scale = availableSize / maxDim; - - SKColor drawColor; - if (StrokeColor != SKColors.Empty) - { - drawColor = StrokeColor; - } - else - { - drawColor = new SKColor((byte)(Color.Red * 255), (byte)(Color.Green * 255), (byte)(Color.Blue * 255), (byte)(Color.Alpha * 255)); - } - - canvas.Save(); - canvas.Translate(center.X, center.Y); - canvas.Scale(scale); - // Center the shape itself (if not centered at 0,0) - canvas.Translate(-bounds.MidX, -bounds.MidY); - - // Draw Glow if enabled - if (IsGlowEnabled) - { - using var glowPaint = new SKPaint - { - Style = SKPaintStyle.StrokeAndFill, - Color = GlowColor, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawPath(BrushShape.Path, glowPaint); - } - - using var paint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = drawColor, - IsAntialias = true - }; - - canvas.DrawPath(BrushShape.Path, paint); - canvas.Restore(); + using var glowPaint = new SKPaint + { + Style = SKPaintStyle.StrokeAndFill, + Color = GlowColor, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) + }; + canvas.DrawPath(BrushShape.Path, glowPaint); } + + using var paint = new SKPaint + { + Style = SKPaintStyle.Fill, + Color = drawColor, + IsAntialias = true + }; + + canvas.DrawPath(BrushShape.Path, paint); + canvas.Restore(); } } diff --git a/Components/BrushesFlyoutPanel.xaml.cs b/Components/BrushesFlyoutPanel.xaml.cs index 3f44b7f..53923db 100644 --- a/Components/BrushesFlyoutPanel.xaml.cs +++ b/Components/BrushesFlyoutPanel.xaml.cs @@ -23,22 +23,21 @@ using LunaDraw.Logic.ViewModels; -namespace LunaDraw.Components +namespace LunaDraw.Components; + +public partial class BrushesFlyoutPanel : ContentView { - public partial class BrushesFlyoutPanel : ContentView + public BrushesFlyoutPanel() { - public BrushesFlyoutPanel() - { - InitializeComponent(); - this.Loaded += OnBrushesFlyoutPanelLoaded; - } + InitializeComponent(); + this.Loaded += OnBrushesFlyoutPanelLoaded; + } - private void OnBrushesFlyoutPanelLoaded(object? sender, EventArgs e) + private void OnBrushesFlyoutPanelLoaded(object? sender, EventArgs e) + { + if (BindingContext is ToolbarViewModel toolbarViewModel) { - if (BindingContext is ToolbarViewModel toolbarViewModel) - { - // No settings to load here anymore, just brush shapes which are data bound - } + // No settings to load here anymore, just brush shapes which are data bound } } } diff --git a/Components/FlyoutPanel.xaml.cs b/Components/FlyoutPanel.xaml.cs index 0566ee2..b0654f8 100644 --- a/Components/FlyoutPanel.xaml.cs +++ b/Components/FlyoutPanel.xaml.cs @@ -23,266 +23,265 @@ using System.Windows.Input; -namespace LunaDraw.Components +namespace LunaDraw.Components; + +public partial class FlyoutPanel : ContentView { - public partial class FlyoutPanel : ContentView + private bool isOpen; + private View? targetElement; + + public static readonly BindableProperty FlyoutContentProperty = + BindableProperty.Create( + nameof(FlyoutContent), + typeof(View), + typeof(FlyoutPanel), + default(View), + propertyChanged: OnFlyoutContentChanged); + + public static readonly BindableProperty IsOpenProperty = + BindableProperty.Create( + nameof(IsOpen), + typeof(bool), + typeof(FlyoutPanel), + default(bool), + propertyChanged: OnIsOpenChanged); + + public static readonly BindableProperty TargetElementProperty = + BindableProperty.Create( + nameof(TargetElement), + typeof(View), + typeof(FlyoutPanel), + default(View), + propertyChanged: OnTargetElementChanged); + + // Optional: name of a child inside TargetElement to use as the anchor (e.g., a specific button inside the toolbar) + public static readonly BindableProperty AnchorNameProperty = + BindableProperty.Create( + nameof(AnchorName), + typeof(string), + typeof(FlyoutPanel), + default(string)); + + public FlyoutPanel() { - private bool isOpen; - private View? targetElement; - - public static readonly BindableProperty FlyoutContentProperty = - BindableProperty.Create( - nameof(FlyoutContent), - typeof(View), - typeof(FlyoutPanel), - default(View), - propertyChanged: OnFlyoutContentChanged); - - public static readonly BindableProperty IsOpenProperty = - BindableProperty.Create( - nameof(IsOpen), - typeof(bool), - typeof(FlyoutPanel), - default(bool), - propertyChanged: OnIsOpenChanged); - - public static readonly BindableProperty TargetElementProperty = - BindableProperty.Create( - nameof(TargetElement), - typeof(View), - typeof(FlyoutPanel), - default(View), - propertyChanged: OnTargetElementChanged); - - // Optional: name of a child inside TargetElement to use as the anchor (e.g., a specific button inside the toolbar) - public static readonly BindableProperty AnchorNameProperty = - BindableProperty.Create( - nameof(AnchorName), - typeof(string), - typeof(FlyoutPanel), - default(string)); - - public FlyoutPanel() - { - InitializeComponent(); + InitializeComponent(); - // Initialize to hidden state - this.Opacity = 0; - this.Scale = 0.9; - AbsoluteLayout.SetLayoutBounds(this, new Rect(-1000, -1000, -1, -1)); - } + // Initialize to hidden state + this.Opacity = 0; + this.Scale = 0.9; + AbsoluteLayout.SetLayoutBounds(this, new Rect(-1000, -1000, -1, -1)); + } - public View FlyoutContent - { - get => (View)GetValue(FlyoutContentProperty); - set => SetValue(FlyoutContentProperty, value); - } + public View FlyoutContent + { + get => (View)GetValue(FlyoutContentProperty); + set => SetValue(FlyoutContentProperty, value); + } - public bool IsOpen - { - get => (bool)GetValue(IsOpenProperty); - set => SetValue(IsOpenProperty, value); - } + public bool IsOpen + { + get => (bool)GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } - public View TargetElement - { - get => (View)GetValue(TargetElementProperty); - set => SetValue(TargetElementProperty, value); - } + public View TargetElement + { + get => (View)GetValue(TargetElementProperty); + set => SetValue(TargetElementProperty, value); + } - public string AnchorName - { - get => (string)GetValue(AnchorNameProperty); - set => SetValue(AnchorNameProperty, value); - } + public string AnchorName + { + get => (string)GetValue(AnchorNameProperty); + set => SetValue(AnchorNameProperty, value); + } - public ICommand? CloseCommand { get; private set; } + public ICommand? CloseCommand { get; private set; } - private static void OnFlyoutContentChanged(BindableObject bindable, object oldValue, object newValue) - { - var panel = (FlyoutPanel)bindable; - // Content is handled by the ContentPresenter in XAML - } + private static void OnFlyoutContentChanged(BindableObject bindable, object oldValue, object newValue) + { + var panel = (FlyoutPanel)bindable; + // Content is handled by the ContentPresenter in XAML + } + + private static void OnIsOpenChanged(BindableObject bindable, object oldValue, object newValue) + { + var panel = (FlyoutPanel)bindable; + panel.isOpen = (bool)newValue; + panel.UpdateVisibility(); + } - private static void OnIsOpenChanged(BindableObject bindable, object oldValue, object newValue) + private static void OnTargetElementChanged(BindableObject bindable, object oldValue, object newValue) + { + var panel = (FlyoutPanel)bindable; + panel.targetElement = (View)newValue; + } + + private async void UpdateVisibility() + { + if (isOpen) { - var panel = (FlyoutPanel)bindable; - panel.isOpen = (bool)newValue; - panel.UpdateVisibility(); + await ShowFlyout(); } - - private static void OnTargetElementChanged(BindableObject bindable, object oldValue, object newValue) + else { - var panel = (FlyoutPanel)bindable; - panel.targetElement = (View)newValue; + await HideFlyout(); } + } - private async void UpdateVisibility() + private async Task ShowFlyout() + { + if (targetElement != null) { - if (isOpen) - { - await ShowFlyout(); - } - else - { - await HideFlyout(); - } + await PositionFlyout(); } - private async Task ShowFlyout() - { - if (targetElement != null) - { - await PositionFlyout(); - } + // Animate in - run fade and scale simultaneously + var fadeTask = this.FadeToAsync(1, 200, Easing.CubicOut); + var scaleTask = this.ScaleToAsync(1, 200, Easing.CubicOut); - // Animate in - run fade and scale simultaneously - var fadeTask = this.FadeToAsync(1, 200, Easing.CubicOut); - var scaleTask = this.ScaleToAsync(1, 200, Easing.CubicOut); + await Task.WhenAll(fadeTask, scaleTask); + } - await Task.WhenAll(fadeTask, scaleTask); - } + private async Task HideFlyout() + { + // Animate out - run fade and scale simultaneously + var fadeTask = this.FadeToAsync(0, 150, Easing.CubicIn); + var scaleTask = this.ScaleToAsync(0.9, 150, Easing.CubicIn); - private async Task HideFlyout() - { - // Animate out - run fade and scale simultaneously - var fadeTask = this.FadeToAsync(0, 150, Easing.CubicIn); - var scaleTask = this.ScaleToAsync(0.9, 150, Easing.CubicIn); + await Task.WhenAll(fadeTask, scaleTask); - await Task.WhenAll(fadeTask, scaleTask); + // Move off-screen when hidden + AbsoluteLayout.SetLayoutBounds(this, new Rect(-1000, -1000, -1, -1)); + } - // Move off-screen when hidden - AbsoluteLayout.SetLayoutBounds(this, new Rect(-1000, -1000, -1, -1)); - } + public static Rect GetCoordinatesWithinPage(VisualElement element) + { + double x = 0; + double y = 0; - public static Rect GetCoordinatesWithinPage(VisualElement element) - { - double x = 0; - double y = 0; + VisualElement? currentElement = element; - VisualElement? currentElement = element; + // Traverse up the visual tree until a Page or null is encountered + while (currentElement != null && !(currentElement is Page)) + { + x += currentElement.X; + y += currentElement.Y; + currentElement = currentElement.Parent as VisualElement; - // Traverse up the visual tree until a Page or null is encountered - while (currentElement != null && !(currentElement is Page)) + if (currentElement is ScrollView scrollView) { - x += currentElement.X; - y += currentElement.Y; - currentElement = currentElement.Parent as VisualElement; - - if (currentElement is ScrollView scrollView) + if (scrollView.Orientation == ScrollOrientation.Both || scrollView.Orientation == ScrollOrientation.Horizontal) { - if (scrollView.Orientation == ScrollOrientation.Both || scrollView.Orientation == ScrollOrientation.Horizontal) - { - x -= scrollView.ScrollX; - } - if (scrollView.Orientation == ScrollOrientation.Both || scrollView.Orientation == ScrollOrientation.Vertical) - { - y -= scrollView.ScrollY; - } + x -= scrollView.ScrollX; + } + if (scrollView.Orientation == ScrollOrientation.Both || scrollView.Orientation == ScrollOrientation.Vertical) + { + y -= scrollView.ScrollY; } } - - // If the element is within a Page, add the Page's X and Y - if (currentElement is Page page) - { - x += page.X; - y += page.Y; - } - - return new Rect(x, y, element.Width, element.Height); } - private async Task PositionFlyout() + // If the element is within a Page, add the Page's X and Y + if (currentElement is Page page) { - if (targetElement == null) return; + x += page.X; + y += page.Y; + } - // Find the actual element to anchor to, if an AnchorName is provided - View anchorElement = targetElement; - if (!string.IsNullOrEmpty(AnchorName)) - { - anchorElement = anchorElement.FindByName(AnchorName) as View ?? targetElement; - } + return new Rect(x, y, element.Width, element.Height); + } - // Compute target bounds relative to the page - var targetBounds = GetCoordinatesWithinPage(anchorElement); // Changed to use anchorElement - if (targetBounds == Rect.Zero) return; + private async Task PositionFlyout() + { + if (targetElement == null) return; - // Position to the right of the target element with a small gap - var x = targetBounds.Right + 10; - var y = targetBounds.Top; + // Find the actual element to anchor to, if an AnchorName is provided + View anchorElement = targetElement; + if (!string.IsNullOrEmpty(AnchorName)) + { + anchorElement = anchorElement.FindByName(AnchorName) as View ?? targetElement; + } - // Set initial bounds using -1 to indicate AutoSize for width/height - AbsoluteLayout.SetLayoutBounds(this, new Rect(x, y, -1, -1)); + // Compute target bounds relative to the page + var targetBounds = GetCoordinatesWithinPage(anchorElement); // Changed to use anchorElement + if (targetBounds == Rect.Zero) return; - // Wait one layout cycle so the FlyoutContainer can size itself - await Task.Yield(); + // Position to the right of the target element with a small gap + var x = targetBounds.Right + 10; + var y = targetBounds.Top; - var flyoutBounds = this.Bounds; + // Set initial bounds using -1 to indicate AutoSize for width/height + AbsoluteLayout.SetLayoutBounds(this, new Rect(x, y, -1, -1)); - // Get parent page dimensions (assume top-level layout fills the page) - if (!(this.Parent is VisualElement parentPage)) return; - var screenWidth = parentPage.Width; - var screenHeight = parentPage.Height; - var margin = 10.0; + // Wait one layout cycle so the FlyoutContainer can size itself + await Task.Yield(); - double finalX = x; - double finalY = y; - double finalWidth = -1; - double finalHeight = -1; + var flyoutBounds = this.Bounds; - // --- Horizontal Positioning Strategy --- + // Get parent page dimensions (assume top-level layout fills the page) + if (!(this.Parent is VisualElement parentPage)) return; + var screenWidth = parentPage.Width; + var screenHeight = parentPage.Height; + var margin = 10.0; - // 1. Try positioning to the right of the target (preferred) - if (flyoutBounds.Right > screenWidth - margin) - { - // It overflows right. Try positioning to the left of the target. - double leftX = targetBounds.Left - flyoutBounds.Width - 10; - if (leftX >= margin) - { - finalX = leftX; - } - else - { - // Neither side fits perfectly. - // Position at the left-most valid position or right-most valid position? - // Let's constrain to the screen width. - finalX = Math.Max(margin, Math.Min(x, screenWidth - flyoutBounds.Width - margin)); - - // If the flyout is wider than the screen (minus margins), constrain width. - if (flyoutBounds.Width > screenWidth - 2 * margin) - { - finalX = margin; - finalWidth = screenWidth - 2 * margin; - } - } - } + double finalX = x; + double finalY = y; + double finalWidth = -1; + double finalHeight = -1; - // --- Vertical Positioning Strategy --- - - // If the flyout overflows the bottom edge - if (flyoutBounds.Bottom > screenHeight - margin) - { - // Try moving it up - double diff = flyoutBounds.Bottom - (screenHeight - margin); - double newY = y - diff; - - if (newY >= margin) - { - finalY = newY; - } - else - { - // Moving up hits the top edge. Constrain Height. - finalY = margin; - finalHeight = screenHeight - 2 * margin; - } - } + // --- Horizontal Positioning Strategy --- + + // 1. Try positioning to the right of the target (preferred) + if (flyoutBounds.Right > screenWidth - margin) + { + // It overflows right. Try positioning to the left of the target. + double leftX = targetBounds.Left - flyoutBounds.Width - 10; + if (leftX >= margin) + { + finalX = leftX; + } + else + { + // Neither side fits perfectly. + // Position at the left-most valid position or right-most valid position? + // Let's constrain to the screen width. + finalX = Math.Max(margin, Math.Min(x, screenWidth - flyoutBounds.Width - margin)); + + // If the flyout is wider than the screen (minus margins), constrain width. + if (flyoutBounds.Width > screenWidth - 2 * margin) + { + finalX = margin; + finalWidth = screenWidth - 2 * margin; + } + } + } + + // --- Vertical Positioning Strategy --- + + // If the flyout overflows the bottom edge + if (flyoutBounds.Bottom > screenHeight - margin) + { + // Try moving it up + double diff = flyoutBounds.Bottom - (screenHeight - margin); + double newY = y - diff; - // Re-apply the adjusted bounds - // Note: AbsoluteLayout in MAUI handles -1 as AutoSize. - // If we set a specific width/height, it will respect it. - AbsoluteLayout.SetLayoutBounds(this, new Rect(finalX, finalY, finalWidth, finalHeight)); + if (newY >= margin) + { + finalY = newY; + } + else + { + // Moving up hits the top edge. Constrain Height. + finalY = margin; + finalHeight = screenHeight - 2 * margin; + } } + + // Re-apply the adjusted bounds + // Note: AbsoluteLayout in MAUI handles -1 as AutoSize. + // If we set a specific width/height, it will respect it. + AbsoluteLayout.SetLayoutBounds(this, new Rect(finalX, finalY, finalWidth, finalHeight)); } } \ No newline at end of file diff --git a/Components/LayerControlView.xaml b/Components/LayerControlView.xaml index e4809ea..da0938b 100644 --- a/Components/LayerControlView.xaml +++ b/Components/LayerControlView.xaml @@ -8,148 +8,178 @@ x:Class="LunaDraw.Components.LayerControlView" x:DataType="viewModels:MainViewModel" x:Name="Root"> - - - - - - - - - - - - - - - diff --git a/Components/LayerControlView.xaml.cs b/Components/LayerControlView.xaml.cs index 5663615..22a9a66 100644 --- a/Components/LayerControlView.xaml.cs +++ b/Components/LayerControlView.xaml.cs @@ -24,68 +24,67 @@ using LunaDraw.Logic.Models; using LunaDraw.Logic.ViewModels; -namespace LunaDraw.Components -{ - public partial class LayerControlView : ContentView - { - public static readonly BindableProperty IsLayerPanelExpandedProperty = - BindableProperty.Create(nameof(IsLayerPanelExpanded), typeof(bool), typeof(LayerControlView), false, propertyChanged: OnIsLayerPanelExpandedChanged); +namespace LunaDraw.Components; - public bool IsLayerPanelExpanded - { - get => (bool)GetValue(IsLayerPanelExpandedProperty); - set => SetValue(IsLayerPanelExpandedProperty, value); - } + public partial class LayerControlView : ContentView + { + public static readonly BindableProperty IsLayerPanelExpandedProperty = + BindableProperty.Create(nameof(IsLayerPanelExpanded), typeof(bool), typeof(LayerControlView), false, propertyChanged: OnIsLayerPanelExpandedChanged); - public List MaskingModes { get; } = Enum.GetValues().Cast().ToList(); + public bool IsLayerPanelExpanded + { + get => (bool)GetValue(IsLayerPanelExpandedProperty); + set => SetValue(IsLayerPanelExpandedProperty, value); + } - public LayerControlView() - { - InitializeComponent(); - } + public List MaskingModes { get; } = Enum.GetValues().Cast().ToList(); - private static void OnIsLayerPanelExpandedChanged(BindableObject bindable, object oldValue, object newValue) - { - var control = (LayerControlView)bindable; - control.ContentGrid.IsVisible = (bool)newValue; - control.CollapseButton.Text = (bool)newValue ? "▼" : "▶"; - } + public LayerControlView() + { + InitializeComponent(); + } - private void OnCollapseClicked(object sender, EventArgs e) - { - IsLayerPanelExpanded = !IsLayerPanelExpanded; - } + private static void OnIsLayerPanelExpandedChanged(BindableObject bindable, object oldValue, object newValue) + { + var control = (LayerControlView)bindable; + control.ContentGrid.IsVisible = (bool)newValue; + control.CollapseButton.Text = (bool)newValue ? "▼" : "▶"; + } - private void OnDragStarting(object sender, DragStartingEventArgs e) - { - if (sender is Element element && element.BindingContext is Layer layer) - { - e.Data.Properties["SourceLayer"] = layer; - // Ensure the dragged layer is selected - if (this.BindingContext is MainViewModel viewModel) - { - viewModel.CurrentLayer = layer; - } - } - } + private void OnCollapseClicked(object sender, EventArgs e) + { + IsLayerPanelExpanded = !IsLayerPanelExpanded; + } - private void OnDragOver(object sender, DragEventArgs e) - { - e.AcceptedOperation = DataPackageOperation.Copy; - } + private void OnDragStarting(object sender, DragStartingEventArgs e) + { + if (sender is Element element && element.BindingContext is Layer layer) + { + e.Data.Properties["SourceLayer"] = layer; + // Ensure the dragged layer is selected + if (this.BindingContext is MainViewModel viewModel) + { + viewModel.CurrentLayer = layer; + } + } + } - private void OnDrop(object sender, DropEventArgs e) - { - if (e.Data.Properties.TryGetValue("SourceLayer", out var sourceObj) && sourceObj is Layer sourceLayer) - { - if (sender is Element element && element.BindingContext is Layer targetLayer) - { - if (this.BindingContext is MainViewModel viewModel) - { - viewModel.ReorderLayer(sourceLayer, targetLayer); - } - } - } - } - } -} + private void OnDragOver(object sender, DragEventArgs e) + { + e.AcceptedOperation = DataPackageOperation.Copy; + } + + private void OnDrop(object sender, DropEventArgs e) + { + if (e.Data.Properties.TryGetValue("SourceLayer", out var sourceObj) && sourceObj is Layer sourceLayer) + { + if (sender is Element element && element.BindingContext is Layer targetLayer) + { + if (this.BindingContext is MainViewModel viewModel) + { + viewModel.ReorderLayer(sourceLayer, targetLayer); + } + } + } + } + } diff --git a/Components/MiniMapView.xaml.cs b/Components/MiniMapView.xaml.cs index 959b758..65bb4c6 100644 --- a/Components/MiniMapView.xaml.cs +++ b/Components/MiniMapView.xaml.cs @@ -32,196 +32,195 @@ using SkiaSharp.Views.Maui; using SkiaSharp.Views.Maui.Controls; -namespace LunaDraw.Components +namespace LunaDraw.Components; + +public partial class MiniMapView : ContentView { - public partial class MiniMapView : ContentView - { - private MainViewModel? viewModel; - private SKMatrix fitMatrix; - private float density = 1.0f; + private MainViewModel? viewModel; + private SKMatrix fitMatrix; + private float density = 1.0f; - private IMessageBus? messageBus; - private IMessageBus? MessageBus + private IMessageBus? messageBus; + private IMessageBus? MessageBus + { + get { - get - { - if (messageBus != null) return messageBus; - messageBus = Handler?.MauiContext?.Services.GetService() - ?? IPlatformApplication.Current?.Services.GetService(); - return messageBus; - } + if (messageBus != null) return messageBus; + messageBus = Handler?.MauiContext?.Services.GetService() + ?? IPlatformApplication.Current?.Services.GetService(); + return messageBus; } + } - public MiniMapView() - { - InitializeComponent(); - - this.Loaded += (s, e) => - { - MessageBus?.Listen() - .Throttle(TimeSpan.FromMilliseconds(30), RxApp.MainThreadScheduler) - .Subscribe(_ => miniMapCanvas?.InvalidateSurface()); - }; - } + public MiniMapView() + { + InitializeComponent(); - protected override void OnBindingContextChanged() + this.Loaded += (s, e) => { - base.OnBindingContextChanged(); - viewModel = BindingContext as MainViewModel; - miniMapCanvas?.InvalidateSurface(); - } + MessageBus?.Listen() + .Throttle(TimeSpan.FromMilliseconds(30), RxApp.MainThreadScheduler) + .Subscribe(_ => miniMapCanvas?.InvalidateSurface()); + }; + } - private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) - { - if (viewModel == null) return; + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + viewModel = BindingContext as MainViewModel; + miniMapCanvas?.InvalidateSurface(); + } - var canvas = e.Surface.Canvas; - var info = e.Info; + private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + if (viewModel == null) return; - if (sender is SKCanvasView view && view.Width > 0) - { - density = (float)(info.Width / view.Width); - } + var canvas = e.Surface.Canvas; + var info = e.Info; - canvas.Clear(SKColors.White); + if (sender is SKCanvasView view && view.Width > 0) + { + density = (float)(info.Width / view.Width); + } + + canvas.Clear(SKColors.White); - // Calculate bounds of all elements - var contentBounds = SKRect.Empty; - bool hasContent = false; + // Calculate bounds of all elements + var contentBounds = SKRect.Empty; + bool hasContent = false; - foreach (var layer in viewModel.Layers) + foreach (var layer in viewModel.Layers) + { + if (!layer.IsVisible) continue; + foreach (var element in layer.Elements) { - if (!layer.IsVisible) continue; - foreach (var element in layer.Elements) + if (!element.IsVisible) continue; + var b = element.Bounds; + if (hasContent) + contentBounds.Union(b); + else { - if (!element.IsVisible) continue; - var b = element.Bounds; - if (hasContent) - contentBounds.Union(b); - else - { - contentBounds = b; - hasContent = true; - } + contentBounds = b; + hasContent = true; } } + } - if (!hasContent) - { - contentBounds = new SKRect(0, 0, 1000, 1000); - } + if (!hasContent) + { + contentBounds = new SKRect(0, 0, 1000, 1000); + } - contentBounds.Inflate(50, 50); + contentBounds.Inflate(50, 50); - // Calculate fit matrix (world to minimap) - float scaleX = info.Width / contentBounds.Width; - float scaleY = info.Height / contentBounds.Height; - float scale = Math.Min(scaleX, scaleY); + // Calculate fit matrix (world to minimap) + float scaleX = info.Width / contentBounds.Width; + float scaleY = info.Height / contentBounds.Height; + float scale = Math.Min(scaleX, scaleY); - float tx = (info.Width - contentBounds.Width * scale) / 2 - contentBounds.Left * scale; - float ty = (info.Height - contentBounds.Height * scale) / 2 - contentBounds.Top * scale; + float tx = (info.Width - contentBounds.Width * scale) / 2 - contentBounds.Left * scale; + float ty = (info.Height - contentBounds.Height * scale) / 2 - contentBounds.Top * scale; - fitMatrix = SKMatrix.CreateScale(scale, scale); - fitMatrix = SKMatrix.Concat(SKMatrix.CreateTranslation(tx, ty), fitMatrix); + fitMatrix = SKMatrix.CreateScale(scale, scale); + fitMatrix = SKMatrix.Concat(SKMatrix.CreateTranslation(tx, ty), fitMatrix); - // Draw content - canvas.Save(); - canvas.Concat(fitMatrix); + // Draw content + canvas.Save(); + canvas.Concat(fitMatrix); - foreach (var layer in viewModel.Layers) + foreach (var layer in viewModel.Layers) + { + if (layer.IsVisible) { - if (layer.IsVisible) + foreach (var element in layer.Elements) { - foreach (var element in layer.Elements) + if (element.IsVisible) { - if (element.IsVisible) - { - element.Draw(canvas); - } + element.Draw(canvas); } } } - canvas.Restore(); + } + canvas.Restore(); - // Draw viewport indicator - if (viewModel.NavigationModel.ViewMatrix.TryInvert(out var mainInverse)) + // Draw viewport indicator + if (viewModel.NavigationModel.ViewMatrix.TryInvert(out var mainInverse)) + { + var mainScreenRect = viewModel.CanvasSize; + if (mainScreenRect.Width > 0) { - var mainScreenRect = viewModel.CanvasSize; - if (mainScreenRect.Width > 0) + // Map screen corners to world points + var tl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Top)); + var tr = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Top)); + var br = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Bottom)); + var bl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Bottom)); + + // Map world points to minimap points + var mTl = fitMatrix.MapPoint(tl); + var mTr = fitMatrix.MapPoint(tr); + var mBr = fitMatrix.MapPoint(br); + var mBl = fitMatrix.MapPoint(bl); + + using var path = new SKPath(); + path.MoveTo(mTl); + path.LineTo(mTr); + path.LineTo(mBr); + path.LineTo(mBl); + path.Close(); + + using var paint = new SKPaint { - // Map screen corners to world points - var tl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Top)); - var tr = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Top)); - var br = mainInverse.MapPoint(new SKPoint(mainScreenRect.Right, mainScreenRect.Bottom)); - var bl = mainInverse.MapPoint(new SKPoint(mainScreenRect.Left, mainScreenRect.Bottom)); - - // Map world points to minimap points - var mTl = fitMatrix.MapPoint(tl); - var mTr = fitMatrix.MapPoint(tr); - var mBr = fitMatrix.MapPoint(br); - var mBl = fitMatrix.MapPoint(bl); - - using var path = new SKPath(); - path.MoveTo(mTl); - path.LineTo(mTr); - path.LineTo(mBr); - path.LineTo(mBl); - path.Close(); - - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.Red, - StrokeWidth = 2, - IsAntialias = true - }; - canvas.DrawPath(path, paint); - - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = SKColors.Red.WithAlpha(50) - }; - canvas.DrawPath(path, fillPaint); - } + Style = SKPaintStyle.Stroke, + Color = SKColors.Red, + StrokeWidth = 2, + IsAntialias = true + }; + canvas.DrawPath(path, paint); + + using var fillPaint = new SKPaint + { + Style = SKPaintStyle.Fill, + Color = SKColors.Red.WithAlpha(50) + }; + canvas.DrawPath(path, fillPaint); } } + } - private void OnTouch(object? sender, SKTouchEventArgs e) - { - if (viewModel == null) return; + private void OnTouch(object? sender, SKTouchEventArgs e) + { + if (viewModel == null) return; - if (!e.InContact) return; + if (!e.InContact) return; - var canvasView = sender as SKCanvasView; - if (canvasView == null) return; + var canvasView = sender as SKCanvasView; + if (canvasView == null) return; - switch (e.ActionType) - { - case SKTouchAction.Pressed: - case SKTouchAction.Moved: - var touchPointPixels = e.Location; + switch (e.ActionType) + { + case SKTouchAction.Pressed: + case SKTouchAction.Moved: + var touchPointPixels = e.Location; - if (fitMatrix.TryInvert(out var inverseFit)) - { - var worldPoint = inverseFit.MapPoint(touchPointPixels); + if (fitMatrix.TryInvert(out var inverseFit)) + { + var worldPoint = inverseFit.MapPoint(touchPointPixels); - // Calculate where this world point currently appears on screen - var currentViewPoint = viewModel.NavigationModel.ViewMatrix.MapPoint(worldPoint); - var screenCenter = new SKPoint(viewModel.CanvasSize.Width / 2, viewModel.CanvasSize.Height / 2); + // Calculate where this world point currently appears on screen + var currentViewPoint = viewModel.NavigationModel.ViewMatrix.MapPoint(worldPoint); + var screenCenter = new SKPoint(viewModel.CanvasSize.Width / 2, viewModel.CanvasSize.Height / 2); - // Calculate the delta to center it - var delta = screenCenter - currentViewPoint; + // Calculate the delta to center it + var delta = screenCenter - currentViewPoint; - // Apply translation to view matrix - var translation = SKMatrix.CreateTranslation(delta.X, delta.Y); - viewModel.NavigationModel.ViewMatrix = viewModel.NavigationModel.ViewMatrix.PostConcat(translation); + // Apply translation to view matrix + var translation = SKMatrix.CreateTranslation(delta.X, delta.Y); + viewModel.NavigationModel.ViewMatrix = viewModel.NavigationModel.ViewMatrix.PostConcat(translation); - MessageBus?.SendMessage(new CanvasInvalidateMessage()); - } - e.Handled = true; - break; - } + MessageBus?.SendMessage(new CanvasInvalidateMessage()); + } + e.Handled = true; + break; } } } \ No newline at end of file diff --git a/Components/SettingsFlyoutPanel.xaml.cs b/Components/SettingsFlyoutPanel.xaml.cs index 93c1455..a8fc38b 100644 --- a/Components/SettingsFlyoutPanel.xaml.cs +++ b/Components/SettingsFlyoutPanel.xaml.cs @@ -28,352 +28,351 @@ using SkiaSharp; -namespace LunaDraw.Components -{ - public partial class SettingsFlyoutPanel : ContentView - { - public static readonly BindableProperty StrokeColorProperty = - BindableProperty.Create( - nameof(StrokeColor), - typeof(SKColor), - typeof(SettingsFlyoutPanel), - SKColors.Black, - propertyChanged: OnStrokeColorPropertyChanged); - - public static readonly BindableProperty FillColorProperty = - BindableProperty.Create( - nameof(FillColor), - typeof(SKColor?), - typeof(SettingsFlyoutPanel), - (SKColor?)null, - propertyChanged: OnFillColorPropertyChanged); - - public static readonly BindableProperty TransparencyProperty = - BindableProperty.Create( - nameof(Transparency), - typeof(byte), - typeof(SettingsFlyoutPanel), - (byte)255, - propertyChanged: OnTransparencyPropertyChanged); - - public static readonly BindableProperty SizeProperty = - BindableProperty.Create( - nameof(Size), - typeof(float), - typeof(SettingsFlyoutPanel), - 5.0f, - propertyChanged: OnSizePropertyChanged); - - public static readonly BindableProperty FlowProperty = - BindableProperty.Create( - nameof(Flow), - typeof(byte), - typeof(SettingsFlyoutPanel), - (byte)255, - propertyChanged: OnFlowPropertyChanged); - - public static readonly BindableProperty SpacingProperty = - BindableProperty.Create( - nameof(Spacing), - typeof(float), - typeof(SettingsFlyoutPanel), - 0.25f, - propertyChanged: OnSpacingPropertyChanged); - - private bool suppressEvents; - - private IMessageBus? messageBus; - private IMessageBus? MessageBus - { - get - { - if (messageBus != null) return messageBus; - messageBus = Handler?.MauiContext?.Services.GetService() - ?? IPlatformApplication.Current?.Services.GetService(); - return messageBus; - } - } +namespace LunaDraw.Components; - public SettingsFlyoutPanel() - { - InitializeComponent(); - this.Loaded += OnSettingsFlyoutPanelLoaded; - } - - private void OnSettingsFlyoutPanelLoaded(object? sender, EventArgs e) - { - if (BindingContext is ToolbarViewModel toolbarViewModel) +public partial class SettingsFlyoutPanel : ContentView +{ + public static readonly BindableProperty StrokeColorProperty = + BindableProperty.Create( + nameof(StrokeColor), + typeof(SKColor), + typeof(SettingsFlyoutPanel), + SKColors.Black, + propertyChanged: OnStrokeColorPropertyChanged); + + public static readonly BindableProperty FillColorProperty = + BindableProperty.Create( + nameof(FillColor), + typeof(SKColor?), + typeof(SettingsFlyoutPanel), + (SKColor?)null, + propertyChanged: OnFillColorPropertyChanged); + + public static readonly BindableProperty TransparencyProperty = + BindableProperty.Create( + nameof(Transparency), + typeof(byte), + typeof(SettingsFlyoutPanel), + (byte)255, + propertyChanged: OnTransparencyPropertyChanged); + + public static readonly BindableProperty SizeProperty = + BindableProperty.Create( + nameof(Size), + typeof(float), + typeof(SettingsFlyoutPanel), + 5.0f, + propertyChanged: OnSizePropertyChanged); + + public static readonly BindableProperty FlowProperty = + BindableProperty.Create( + nameof(Flow), + typeof(byte), + typeof(SettingsFlyoutPanel), + (byte)255, + propertyChanged: OnFlowPropertyChanged); + + public static readonly BindableProperty SpacingProperty = + BindableProperty.Create( + nameof(Spacing), + typeof(float), + typeof(SettingsFlyoutPanel), + 0.25f, + propertyChanged: OnSpacingPropertyChanged); + + private bool suppressEvents; + + private IMessageBus? messageBus; + private IMessageBus? MessageBus + { + get { - // Set initial values from ViewModel - StrokeColorPicker.PickedColor = SKColorToMauiColor(toolbarViewModel.StrokeColor); - - if (toolbarViewModel.FillColor.HasValue) - FillColorPicker.PickedColor = SKColorToMauiColor(toolbarViewModel.FillColor.Value); - - TransparencySlider.Value = toolbarViewModel.Opacity; - SizeSlider.Value = toolbarViewModel.StrokeWidth; - FlowSlider.Value = toolbarViewModel.Flow; - SpacingSlider.Value = toolbarViewModel.Spacing; - - GlowSwitch.IsToggled = toolbarViewModel.IsGlowEnabled; - GlowRadiusSlider.Value = toolbarViewModel.GlowRadius; - RainbowSwitch.IsToggled = toolbarViewModel.IsRainbowEnabled; - ScatterSlider.Value = toolbarViewModel.ScatterRadius; - SizeJitterSlider.Value = toolbarViewModel.SizeJitter; - AngleJitterSlider.Value = toolbarViewModel.AngleJitter; - HueJitterSlider.Value = toolbarViewModel.HueJitter; + if (messageBus != null) return messageBus; + messageBus = Handler?.MauiContext?.Services.GetService() + ?? IPlatformApplication.Current?.Services.GetService(); + return messageBus; } - } - - public SKColor StrokeColor - { - get => (SKColor)GetValue(StrokeColorProperty); - set => SetValue(StrokeColorProperty, value); - } + } - public SKColor? FillColor - { - get => (SKColor?)GetValue(FillColorProperty); - set => SetValue(FillColorProperty, value); - } + public SettingsFlyoutPanel() + { + InitializeComponent(); + this.Loaded += OnSettingsFlyoutPanelLoaded; + } - public byte Transparency + private void OnSettingsFlyoutPanelLoaded(object? sender, EventArgs e) + { + if (BindingContext is ToolbarViewModel toolbarViewModel) { - get => (byte)GetValue(TransparencyProperty); - set => SetValue(TransparencyProperty, value); - } + // Set initial values from ViewModel + StrokeColorPicker.PickedColor = SKColorToMauiColor(toolbarViewModel.StrokeColor); - public float Size - { - get => (float)GetValue(SizeProperty); - set => SetValue(SizeProperty, value); - } + if (toolbarViewModel.FillColor.HasValue) + FillColorPicker.PickedColor = SKColorToMauiColor(toolbarViewModel.FillColor.Value); - public byte Flow - { - get => (byte)GetValue(FlowProperty); - set => SetValue(FlowProperty, value); - } + TransparencySlider.Value = toolbarViewModel.Opacity; + SizeSlider.Value = toolbarViewModel.StrokeWidth; + FlowSlider.Value = toolbarViewModel.Flow; + SpacingSlider.Value = toolbarViewModel.Spacing; - public float Spacing - { - get => (float)GetValue(SpacingProperty); - set => SetValue(SpacingProperty, value); + GlowSwitch.IsToggled = toolbarViewModel.IsGlowEnabled; + GlowRadiusSlider.Value = toolbarViewModel.GlowRadius; + RainbowSwitch.IsToggled = toolbarViewModel.IsRainbowEnabled; + ScatterSlider.Value = toolbarViewModel.ScatterRadius; + SizeJitterSlider.Value = toolbarViewModel.SizeJitter; + AngleJitterSlider.Value = toolbarViewModel.AngleJitter; + HueJitterSlider.Value = toolbarViewModel.HueJitter; } + } - private static void OnStrokeColorPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - try - { - var panel = (SettingsFlyoutPanel)bindable; - if (panel.BindingContext is ToolbarViewModel toolbarViewModel && newValue is SKColor color) - { - if (panel.MessageBus != null) - panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(strokeColor: color)); - if (panel.StrokeColorPicker != null) - panel.StrokeColorPicker.PickedColor = SKColorToMauiColor(color); - } - } - catch { } - } + public SKColor StrokeColor + { + get => (SKColor)GetValue(StrokeColorProperty); + set => SetValue(StrokeColorProperty, value); + } - private static void OnFillColorPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - try - { - var panel = (SettingsFlyoutPanel)bindable; - if (panel.BindingContext is ToolbarViewModel toolbarViewModel) - { - var fill = newValue as SKColor?; - if (panel.MessageBus != null) - panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(fillColor: fill)); - if (newValue is SKColor fillColor && panel.FillColorPicker != null) - panel.FillColorPicker.PickedColor = SKColorToMauiColor(fillColor); - } - } - catch { } - } + public SKColor? FillColor + { + get => (SKColor?)GetValue(FillColorProperty); + set => SetValue(FillColorProperty, value); + } - private static void OnTransparencyPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - try - { - var panel = (SettingsFlyoutPanel)bindable; - if (panel.BindingContext is ToolbarViewModel toolbarViewModel && newValue is byte transparency) - { - if (panel.MessageBus != null) - panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(transparency: transparency)); - if (panel.TransparencySlider != null) - panel.TransparencySlider.Value = transparency; - } - } - catch { } - } + public byte Transparency + { + get => (byte)GetValue(TransparencyProperty); + set => SetValue(TransparencyProperty, value); + } - private static void OnSizePropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - try - { - var panel = (SettingsFlyoutPanel)bindable; - if (panel.BindingContext is ToolbarViewModel && newValue is float size) - { - if (panel.MessageBus != null) - panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(strokeWidth: size)); - if (panel.SizeSlider != null) - panel.SizeSlider.Value = size; - } - } - catch { } - } + public float Size + { + get => (float)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } - private static void OnFlowPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - try - { - var panel = (SettingsFlyoutPanel)bindable; - if (panel.BindingContext is ToolbarViewModel && newValue is byte flow) - { - if (panel.MessageBus != null) - panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(flow: flow)); - if (panel.FlowSlider != null) - panel.FlowSlider.Value = flow; - } - } - catch { } - } + public byte Flow + { + get => (byte)GetValue(FlowProperty); + set => SetValue(FlowProperty, value); + } - private static void OnSpacingPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - try - { - var panel = (SettingsFlyoutPanel)bindable; - if (panel.BindingContext is ToolbarViewModel && newValue is float spacing) - { - if (panel.MessageBus != null) - panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(spacing: spacing)); - if (panel.SpacingSlider != null) - panel.SpacingSlider.Value = spacing; - } - } - catch { } - } + public float Spacing + { + get => (float)GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } - private void OnStrokeColorChanged(object sender, EventArgs e) - { - if (sender is Maui.ColorPicker.ColorPicker colorPicker) + private static void OnStrokeColorPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + try { - var strokeColor = MauiColorToSKColor(colorPicker.PickedColor); - MessageBus?.SendMessage(new BrushSettingsChangedMessage(strokeColor: strokeColor)); + var panel = (SettingsFlyoutPanel)bindable; + if (panel.BindingContext is ToolbarViewModel toolbarViewModel && newValue is SKColor color) + { + if (panel.MessageBus != null) + panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(strokeColor: color)); + if (panel.StrokeColorPicker != null) + panel.StrokeColorPicker.PickedColor = SKColorToMauiColor(color); + } } - } - - private void OnFillColorChanged(object sender, EventArgs e) - { - if (suppressEvents) return; + catch { } + } - if (sender is Maui.ColorPicker.ColorPicker colorPicker) + private static void OnFillColorPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + try { - var fillColor = MauiColorToSKColor(colorPicker.PickedColor); - MessageBus?.SendMessage(new BrushSettingsChangedMessage(fillColor: fillColor)); + var panel = (SettingsFlyoutPanel)bindable; + if (panel.BindingContext is ToolbarViewModel toolbarViewModel) + { + var fill = newValue as SKColor?; + if (panel.MessageBus != null) + panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(fillColor: fill)); + if (newValue is SKColor fillColor && panel.FillColorPicker != null) + panel.FillColorPicker.PickedColor = SKColorToMauiColor(fillColor); + } } - } - - private void OnTransparencyChanged(object sender, ValueChangedEventArgs e) - { - var transparency = (byte)e.NewValue; - MessageBus?.SendMessage(new BrushSettingsChangedMessage(transparency: transparency)); - } - - private void OnSizeChanged(object sender, ValueChangedEventArgs e) - { - var size = (float)e.NewValue; - MessageBus?.SendMessage(new BrushSettingsChangedMessage(strokeWidth: size)); - } + catch { } + } - private void OnFlowChanged(object sender, ValueChangedEventArgs e) - { - var flow = (byte)e.NewValue; - MessageBus?.SendMessage(new BrushSettingsChangedMessage(flow: flow)); - } + private static void OnTransparencyPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + try + { + var panel = (SettingsFlyoutPanel)bindable; + if (panel.BindingContext is ToolbarViewModel toolbarViewModel && newValue is byte transparency) + { + if (panel.MessageBus != null) + panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(transparency: transparency)); + if (panel.TransparencySlider != null) + panel.TransparencySlider.Value = transparency; + } + } + catch { } + } - private void OnSpacingChanged(object sender, ValueChangedEventArgs e) - { - var spacing = (float)e.NewValue; - MessageBus?.SendMessage(new BrushSettingsChangedMessage(spacing: spacing)); - } + private static void OnSizePropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + try + { + var panel = (SettingsFlyoutPanel)bindable; + if (panel.BindingContext is ToolbarViewModel && newValue is float size) + { + if (panel.MessageBus != null) + panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(strokeWidth: size)); + if (panel.SizeSlider != null) + panel.SizeSlider.Value = size; + } + } + catch { } + } - private void OnNoFillClicked(object sender, EventArgs e) - { - suppressEvents = true; + private static void OnFlowPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { try { - FillColorPicker.PickedColor = Colors.Transparent; // Clear the color picker visually + var panel = (SettingsFlyoutPanel)bindable; + if (panel.BindingContext is ToolbarViewModel && newValue is byte flow) + { + if (panel.MessageBus != null) + panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(flow: flow)); + if (panel.FlowSlider != null) + panel.FlowSlider.Value = flow; + } } - finally + catch { } + } + + private static void OnSpacingPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + try { - suppressEvents = false; + var panel = (SettingsFlyoutPanel)bindable; + if (panel.BindingContext is ToolbarViewModel && newValue is float spacing) + { + if (panel.MessageBus != null) + panel.MessageBus.SendMessage(new BrushSettingsChangedMessage(spacing: spacing)); + if (panel.SpacingSlider != null) + panel.SpacingSlider.Value = spacing; + } } - MessageBus?.SendMessage(new BrushSettingsChangedMessage(shouldClearFillColor: true)); - } + catch { } + } - private static Color SKColorToMauiColor(SKColor skColor) + private void OnStrokeColorChanged(object sender, EventArgs e) + { + if (sender is Maui.ColorPicker.ColorPicker colorPicker) { - return Color.FromRgba(skColor.Red, skColor.Green, skColor.Blue, skColor.Alpha); + var strokeColor = MauiColorToSKColor(colorPicker.PickedColor); + MessageBus?.SendMessage(new BrushSettingsChangedMessage(strokeColor: strokeColor)); } + } - private static SKColor MauiColorToSKColor(Color mauiColor) + private void OnFillColorChanged(object sender, EventArgs e) + { + if (suppressEvents) return; + + if (sender is Maui.ColorPicker.ColorPicker colorPicker) { - return new SKColor( - (byte)((mauiColor?.Red ?? 0) * 255), - (byte)((mauiColor?.Green ?? 0) * 255), - (byte)((mauiColor?.Blue ?? 0) * 255), - (byte)((mauiColor?.Alpha ?? 0) * 255)); + var fillColor = MauiColorToSKColor(colorPicker.PickedColor); + MessageBus?.SendMessage(new BrushSettingsChangedMessage(fillColor: fillColor)); } + } + + private void OnTransparencyChanged(object sender, ValueChangedEventArgs e) + { + var transparency = (byte)e.NewValue; + MessageBus?.SendMessage(new BrushSettingsChangedMessage(transparency: transparency)); + } + + private void OnSizeChanged(object sender, ValueChangedEventArgs e) + { + var size = (float)e.NewValue; + MessageBus?.SendMessage(new BrushSettingsChangedMessage(strokeWidth: size)); + } + + private void OnFlowChanged(object sender, ValueChangedEventArgs e) + { + var flow = (byte)e.NewValue; + MessageBus?.SendMessage(new BrushSettingsChangedMessage(flow: flow)); + } - private void OnGlowSwitchToggled(object sender, ToggledEventArgs e) + private void OnSpacingChanged(object sender, ValueChangedEventArgs e) + { + var spacing = (float)e.NewValue; + MessageBus?.SendMessage(new BrushSettingsChangedMessage(spacing: spacing)); + } + + private void OnNoFillClicked(object sender, EventArgs e) + { + suppressEvents = true; + try { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(isGlowEnabled: e.Value)); + FillColorPicker.PickedColor = Colors.Transparent; // Clear the color picker visually } - - private void OnGlowRadiusChanged(object sender, ValueChangedEventArgs e) + finally { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(glowRadius: (float)e.NewValue)); + suppressEvents = false; } + MessageBus?.SendMessage(new BrushSettingsChangedMessage(shouldClearFillColor: true)); + } + + private static Color SKColorToMauiColor(SKColor skColor) + { + return Color.FromRgba(skColor.Red, skColor.Green, skColor.Blue, skColor.Alpha); + } + + private static SKColor MauiColorToSKColor(Color mauiColor) + { + return new SKColor( + (byte)((mauiColor?.Red ?? 0) * 255), + (byte)((mauiColor?.Green ?? 0) * 255), + (byte)((mauiColor?.Blue ?? 0) * 255), + (byte)((mauiColor?.Alpha ?? 0) * 255)); + } + + private void OnGlowSwitchToggled(object sender, ToggledEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(isGlowEnabled: e.Value)); + } + + private void OnGlowRadiusChanged(object sender, ValueChangedEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(glowRadius: (float)e.NewValue)); + } - private void OnGlowColorTapped(object sender, TappedEventArgs e) + private void OnGlowColorTapped(object sender, TappedEventArgs e) + { + if (e.Parameter is string hexColor) { - if (e.Parameter is string hexColor) + if (SKColor.TryParse(hexColor, out var color)) { - if (SKColor.TryParse(hexColor, out var color)) - { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(glowColor: color)); - } + MessageBus?.SendMessage(new BrushSettingsChangedMessage(glowColor: color)); } } + } - private void OnRainbowSwitchToggled(object sender, ToggledEventArgs e) - { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(isRainbowEnabled: e.Value)); - } + private void OnRainbowSwitchToggled(object sender, ToggledEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(isRainbowEnabled: e.Value)); + } - private void OnScatterChanged(object sender, ValueChangedEventArgs e) - { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(scatterRadius: (float)e.NewValue)); - } + private void OnScatterChanged(object sender, ValueChangedEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(scatterRadius: (float)e.NewValue)); + } - private void OnSizeJitterChanged(object sender, ValueChangedEventArgs e) - { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(sizeJitter: (float)e.NewValue)); - } + private void OnSizeJitterChanged(object sender, ValueChangedEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(sizeJitter: (float)e.NewValue)); + } - private void OnAngleJitterChanged(object sender, ValueChangedEventArgs e) - { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(angleJitter: (float)e.NewValue)); - } + private void OnAngleJitterChanged(object sender, ValueChangedEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(angleJitter: (float)e.NewValue)); + } - private void OnHueJitterChanged(object sender, ValueChangedEventArgs e) - { - MessageBus?.SendMessage(new BrushSettingsChangedMessage(hueJitter: (float)e.NewValue)); - } + private void OnHueJitterChanged(object sender, ValueChangedEventArgs e) + { + MessageBus?.SendMessage(new BrushSettingsChangedMessage(hueJitter: (float)e.NewValue)); } } diff --git a/Components/ShapePreviewControl.cs b/Components/ShapePreviewControl.cs index 9f291b9..f8da966 100644 --- a/Components/ShapePreviewControl.cs +++ b/Components/ShapePreviewControl.cs @@ -27,118 +27,117 @@ using SkiaSharp.Views.Maui; using SkiaSharp.Views.Maui.Controls; -namespace LunaDraw.Components +namespace LunaDraw.Components; + +public class ShapePreviewControl : SKCanvasView { - public class ShapePreviewControl : SKCanvasView + public ShapePreviewControl() { - public ShapePreviewControl() - { - Loaded += (s, e) => InvalidateSurface(); - } - public static readonly BindableProperty ActiveToolProperty = - BindableProperty.Create(nameof(ActiveTool), typeof(IDrawingTool), typeof(ShapePreviewControl), null, propertyChanged: OnPropertyChanged); + Loaded += (s, e) => InvalidateSurface(); + } + public static readonly BindableProperty ActiveToolProperty = + BindableProperty.Create(nameof(ActiveTool), typeof(IDrawingTool), typeof(ShapePreviewControl), null, propertyChanged: OnPropertyChanged); - public IDrawingTool ActiveTool - { - get => (IDrawingTool)GetValue(ActiveToolProperty); - set => SetValue(ActiveToolProperty, value); - } + public IDrawingTool ActiveTool + { + get => (IDrawingTool)GetValue(ActiveToolProperty); + set => SetValue(ActiveToolProperty, value); + } - public static readonly BindableProperty ShapeNameProperty = - BindableProperty.Create(nameof(ShapeName), typeof(string), typeof(ShapePreviewControl), null, propertyChanged: OnPropertyChanged); + public static readonly BindableProperty ShapeNameProperty = + BindableProperty.Create(nameof(ShapeName), typeof(string), typeof(ShapePreviewControl), null, propertyChanged: OnPropertyChanged); - public string ShapeName - { - get => (string)GetValue(ShapeNameProperty); - set => SetValue(ShapeNameProperty, value); - } + public string ShapeName + { + get => (string)GetValue(ShapeNameProperty); + set => SetValue(ShapeNameProperty, value); + } - public static readonly BindableProperty StrokeColorProperty = - BindableProperty.Create(nameof(StrokeColor), typeof(SKColor), typeof(ShapePreviewControl), SKColors.Black, propertyChanged: OnPropertyChanged); + public static readonly BindableProperty StrokeColorProperty = + BindableProperty.Create(nameof(StrokeColor), typeof(SKColor), typeof(ShapePreviewControl), SKColors.Black, propertyChanged: OnPropertyChanged); - public SKColor StrokeColor - { - get => (SKColor)GetValue(StrokeColorProperty); - set => SetValue(StrokeColorProperty, value); - } + public SKColor StrokeColor + { + get => (SKColor)GetValue(StrokeColorProperty); + set => SetValue(StrokeColorProperty, value); + } - public static readonly BindableProperty FillColorProperty = - BindableProperty.Create(nameof(FillColor), typeof(SKColor?), typeof(ShapePreviewControl), null, propertyChanged: OnPropertyChanged); + public static readonly BindableProperty FillColorProperty = + BindableProperty.Create(nameof(FillColor), typeof(SKColor?), typeof(ShapePreviewControl), null, propertyChanged: OnPropertyChanged); - public SKColor? FillColor - { - get => (SKColor?)GetValue(FillColorProperty); - set => SetValue(FillColorProperty, value); - } + public SKColor? FillColor + { + get => (SKColor?)GetValue(FillColorProperty); + set => SetValue(FillColorProperty, value); + } - public static readonly BindableProperty StrokeWidthProperty = - BindableProperty.Create(nameof(StrokeWidth), typeof(float), typeof(ShapePreviewControl), 5f, propertyChanged: OnPropertyChanged); + public static readonly BindableProperty StrokeWidthProperty = + BindableProperty.Create(nameof(StrokeWidth), typeof(float), typeof(ShapePreviewControl), 5f, propertyChanged: OnPropertyChanged); - public float StrokeWidth - { - get => (float)GetValue(StrokeWidthProperty); - set => SetValue(StrokeWidthProperty, value); - } + public float StrokeWidth + { + get => (float)GetValue(StrokeWidthProperty); + set => SetValue(StrokeWidthProperty, value); + } - private static void OnPropertyChanged(BindableObject bindable, object oldValue, object newValue) - { - ((ShapePreviewControl)bindable).InvalidateSurface(); - } + private static void OnPropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + ((ShapePreviewControl)bindable).InvalidateSurface(); + } - protected override void OnPaintSurface(SKPaintSurfaceEventArgs e) - { - base.OnPaintSurface(e); + protected override void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + base.OnPaintSurface(e); + + var canvas = e.Surface.Canvas; + canvas.Clear(); - var canvas = e.Surface.Canvas; - canvas.Clear(); + if (ActiveTool == null && string.IsNullOrEmpty(ShapeName)) return; - if (ActiveTool == null && string.IsNullOrEmpty(ShapeName)) return; + var info = e.Info; + float width = info.Width; + float height = info.Height; + float padding = width * 0.2f; + var rect = new SKRect(padding, padding, width - padding, height - padding); - var info = e.Info; - float width = info.Width; - float height = info.Height; - float padding = width * 0.2f; - var rect = new SKRect(padding, padding, width - padding, height - padding); + using var paint = new SKPaint + { + IsAntialias = true, + Color = StrokeColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = Math.Min(StrokeWidth, width * 0.1f) // Limit stroke width for preview + }; - using var paint = new SKPaint + if (FillColor.HasValue) + { + using var fillPaint = new SKPaint { IsAntialias = true, - Color = StrokeColor, - Style = SKPaintStyle.Stroke, - StrokeWidth = Math.Min(StrokeWidth, width * 0.1f) // Limit stroke width for preview + Color = FillColor.Value, + Style = SKPaintStyle.Fill }; - if (FillColor.HasValue) - { - using var fillPaint = new SKPaint - { - IsAntialias = true, - Color = FillColor.Value, - Style = SKPaintStyle.Fill - }; - - if ((ActiveTool is RectangleTool) || ShapeName == "Rectangle") - { - canvas.DrawRect(rect, fillPaint); - } - else if ((ActiveTool is EllipseTool) || ShapeName == "Circle") - { - canvas.DrawOval(rect, fillPaint); - } - } - if ((ActiveTool is RectangleTool) || ShapeName == "Rectangle") { - canvas.DrawRect(rect, paint); + canvas.DrawRect(rect, fillPaint); } else if ((ActiveTool is EllipseTool) || ShapeName == "Circle") { - canvas.DrawOval(rect, paint); - } - else if ((ActiveTool is LineTool) || ShapeName == "Line") - { - canvas.DrawLine(rect.Left, rect.Bottom, rect.Right, rect.Top, paint); + canvas.DrawOval(rect, fillPaint); } } + + if ((ActiveTool is RectangleTool) || ShapeName == "Rectangle") + { + canvas.DrawRect(rect, paint); + } + else if ((ActiveTool is EllipseTool) || ShapeName == "Circle") + { + canvas.DrawOval(rect, paint); + } + else if ((ActiveTool is LineTool) || ShapeName == "Line") + { + canvas.DrawLine(rect.Left, rect.Bottom, rect.Right, rect.Top, paint); + } } } diff --git a/Components/ShapesFlyoutPanel.xaml.cs b/Components/ShapesFlyoutPanel.xaml.cs index 1b43f14..8fa92cf 100644 --- a/Components/ShapesFlyoutPanel.xaml.cs +++ b/Components/ShapesFlyoutPanel.xaml.cs @@ -21,13 +21,12 @@ * */ -namespace LunaDraw.Components -{ - public partial class ShapesFlyoutPanel : ContentView - { - public ShapesFlyoutPanel() - { - InitializeComponent(); - } - } -} +namespace LunaDraw.Components; + + public partial class ShapesFlyoutPanel : ContentView + { + public ShapesFlyoutPanel() + { + InitializeComponent(); + } + } diff --git a/Converters/BoolToEyeIconConverter.cs b/Converters/BoolToEyeIconConverter.cs index c5a5cdf..3fcd45c 100644 --- a/Converters/BoolToEyeIconConverter.cs +++ b/Converters/BoolToEyeIconConverter.cs @@ -23,22 +23,21 @@ using System.Globalization; -namespace LunaDraw.Converters -{ - public class BoolToEyeIconConverter : IValueConverter - { - public object Convert(object ?value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isVisible) - { - return isVisible ? "👁" : "○"; // Eye vs Empty Circle - } - return "○"; - } +namespace LunaDraw.Converters; - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return value is string str && str == "👁"; - } - } -} \ No newline at end of file + public class BoolToEyeIconConverter : IValueConverter + { + public object Convert(object ?value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isVisible) + { + return isVisible ? "👁" : "○"; // Eye vs Empty Circle + } + return "○"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is string str && str == "👁"; + } + } \ No newline at end of file diff --git a/Converters/BoolToLayerPanelWidthConverter.cs b/Converters/BoolToLayerPanelWidthConverter.cs index bbf3636..4d57f7c 100644 --- a/Converters/BoolToLayerPanelWidthConverter.cs +++ b/Converters/BoolToLayerPanelWidthConverter.cs @@ -23,23 +23,22 @@ using System.Globalization; -namespace LunaDraw.Converters +namespace LunaDraw.Converters; + +public class BoolToLayerPanelWidthConverter : IValueConverter { - public class BoolToLayerPanelWidthConverter : IValueConverter + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isExpanded) { - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isExpanded) - { - return isExpanded ? 300.0 : 120.0; - } + return isExpanded ? 350.0 : 150.0; + } - return 300.0; - } + return 350.0; + } - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - return 300.0; - } - } + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return 350.0; + } } diff --git a/Converters/BoolToLockIconConverter.cs b/Converters/BoolToLockIconConverter.cs index 3d2d5e5..3d879e3 100644 --- a/Converters/BoolToLockIconConverter.cs +++ b/Converters/BoolToLockIconConverter.cs @@ -23,22 +23,21 @@ using System.Globalization; -namespace LunaDraw.Converters -{ - public class BoolToLockIconConverter : IValueConverter - { - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is bool isLocked) - { - return isLocked ? "🔒" : "🔓"; - } - return "🔓"; - } +namespace LunaDraw.Converters; - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file + public class BoolToLockIconConverter : IValueConverter + { + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isLocked) + { + return isLocked ? "🔒" : "🔓"; + } + return "🔓"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } \ No newline at end of file diff --git a/Converters/ColorToHexConverter.cs b/Converters/ColorToHexConverter.cs index 59228fa..60b92b6 100644 --- a/Converters/ColorToHexConverter.cs +++ b/Converters/ColorToHexConverter.cs @@ -24,47 +24,46 @@ using SkiaSharp; using System.Globalization; -namespace LunaDraw.Converters -{ - public class ColorToHexConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value == null) return string.Empty; +namespace LunaDraw.Converters; - if (value is SKColor color) - { - return $"#{color.Red:X2}{color.Green:X2}{color.Blue:X2}"; - } + public class ColorToHexConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value == null) return string.Empty; - return string.Empty; - } + if (value is SKColor color) + { + return $"#{color.Red:X2}{color.Green:X2}{color.Blue:X2}"; + } - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is string hexString && !string.IsNullOrWhiteSpace(hexString)) - { - hexString = hexString.TrimStart('#'); + return string.Empty; + } - if (hexString.Length == 6) - { - try - { - byte r = System.Convert.ToByte(hexString.Substring(0, 2), 16); - byte g = System.Convert.ToByte(hexString.Substring(2, 2), 16); - byte b = System.Convert.ToByte(hexString.Substring(4, 2), 16); - return new SKColor(r, g, b); - } - catch - { - // Return a safe default color on conversion failure - return SKColors.Black; - } - } - } + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hexString && !string.IsNullOrWhiteSpace(hexString)) + { + hexString = hexString.TrimStart('#'); - // Return a safe default color if the input is invalid or empty - return SKColors.Black; - } - } -} + if (hexString.Length == 6) + { + try + { + byte r = System.Convert.ToByte(hexString.Substring(0, 2), 16); + byte g = System.Convert.ToByte(hexString.Substring(2, 2), 16); + byte b = System.Convert.ToByte(hexString.Substring(4, 2), 16); + return new SKColor(r, g, b); + } + catch + { + // Return a safe default color on conversion failure + return SKColors.Black; + } + } + } + + // Return a safe default color if the input is invalid or empty + return SKColors.Black; + } + } diff --git a/Converters/ToolNameToIconConverter.cs b/Converters/ToolNameToIconConverter.cs index dcbc904..c84c453 100644 --- a/Converters/ToolNameToIconConverter.cs +++ b/Converters/ToolNameToIconConverter.cs @@ -23,42 +23,41 @@ using System.Globalization; -namespace LunaDraw.Converters -{ - // Simple converter that maps tool names to an icon glyph (emoji for now). - // This keeps UI independent of a specific icon font and allows swapping to Syncfusion glyphs later. - public class ToolNameToIconConverter : IValueConverter - { - private static readonly Dictionary Map = new(StringComparer.OrdinalIgnoreCase) - { - { "Select", "🔲" }, - { "Line", "/" }, - { "Rectangle", "▭" }, - { "Ellipse", "◯" }, - { "Freehand", "✏️" }, - { "Eraser", "🧽" }, - { "Fill", "🖌️" }, - // Fallback for unknown tools - { "Default", "🔧" } - }; +namespace LunaDraw.Converters; - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is string name && !string.IsNullOrWhiteSpace(name)) +// Simple converter that maps tool names to an icon glyph (emoji for now). +// This keeps UI independent of a specific icon font and allows swapping to Syncfusion glyphs later. +public class ToolNameToIconConverter : IValueConverter +{ + private static readonly Dictionary Map = new(StringComparer.OrdinalIgnoreCase) { - if (Map.TryGetValue(name, out var glyph)) - return glyph; + { "Select", "🔲" }, + { "Line", "/" }, + { "Rectangle", "▭" }, + { "Ellipse", "◯" }, + { "Freehand", "✏️" }, + { "Eraser", "🧽" }, + { "Fill", "🖌️" }, + // Fallback for unknown tools + { "Default", "🔧" } + }; - // Try to return the first character as a fallback - return name.Length > 0 ? name.Substring(0, 1) : Map["Default"]; - } + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string name && !string.IsNullOrWhiteSpace(name)) + { + if (Map.TryGetValue(name, out var glyph)) + return glyph; - return Map["Default"]; + // Try to return the first character as a fallback + return name.Length > 0 ? name.Substring(0, 1) : Map["Default"]; } - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } + return Map["Default"]; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); } } diff --git a/Converters/ToolToColorConverter.cs b/Converters/ToolToColorConverter.cs index 12da3bf..cd37e70 100644 --- a/Converters/ToolToColorConverter.cs +++ b/Converters/ToolToColorConverter.cs @@ -54,7 +54,7 @@ public class ToolToColorConverter : IValueConverter // Inactive Color var isDark = Application.Current?.RequestedTheme == AppTheme.Dark; var key = isDark ? "Gray700" : "Gray200"; - + if (Application.Current?.Resources.TryGetValue(key, out var inactiveColor) == true) return inactiveColor; diff --git a/Documentation/TransparentBackgroundExample.md b/Documentation/TransparentBackgroundExample.md new file mode 100644 index 0000000..51f73a9 --- /dev/null +++ b/Documentation/TransparentBackgroundExample.md @@ -0,0 +1,160 @@ +Looking at the DWMBlurGlass project and your goal of achieving full crystal transparency in modern Windows apps, here's what you need to know: + +## The Challenge + +The `CreateHostBackdropBrush()` method and WinUI 3's built-in backdrop materials (Mica, Acrylic) **cannot achieve 100% transparency** because: + +1. They're designed as _backdrop effects_, not true transparency +2. User settings and power policies can override them +3. They sample content behind the window, but with blurring/tinting effects + +## Modern Approaches (Without WinUIEx) + +### Option 1: Direct DWM API Calls (Like DWMBlurGlass Does) + +DWMBlurGlass achieves crystal transparency by directly calling Windows DWM (Desktop Window Manager) APIs. You can do this in your app: + +```csharp +using System.Runtime.InteropServices; + +public class TransparencyHelper +{ + [DllImport("dwmapi.dll")] + private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); + + [StructLayout(LayoutKind.Sequential)] + private struct MARGINS + { + public int cxLeftWidth; + public int cxRightWidth; + public int cyTopHeight; + public int cyBottomHeight; + } + + private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + private const int DWMWA_SYSTEMBACKDROP_TYPE = 38; + + public static void EnableTransparency(IntPtr hwnd) + { + // Extend glass into entire client area + var margins = new MARGINS { + cxLeftWidth = -1, + cxRightWidth = -1, + cyTopHeight = -1, + cyBottomHeight = -1 + }; + DwmExtendFrameIntoClientArea(hwnd, ref margins); + + // Optional: Set backdrop type + int backdropType = 2; // 2 = Acrylic, 3 = Mica, 4 = Mica Alt + DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, ref backdropType, sizeof(int)); + } +} +``` + +### Option 2: Use Windows.Graphics.Capture API + +For true transparency with content behind your window: + +```csharp +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX.Direct3D11; + +// Capture the desktop behind your window +// This requires Windows 10 1903+ +var picker = new GraphicsCapturePicker(); +var item = await picker.PickSingleItemAsync(); +// Apply this as a brush to your window +``` + +### Option 3: Composition API with Custom Implementation + +Create a custom backdrop using the Composition API directly: + +```csharp +using Windows.UI.Composition; +using Microsoft.UI.Composition.SystemBackdrops; + +public class CrystalBackdrop : IDisposable +{ + private CompositionBrush _brush; + + public void Apply(Window window) + { + var compositor = window.Compositor; + + // Create host backdrop brush + var backdropBrush = compositor.CreateHostBackdropBrush(); + + // Create visual for your window + var visual = compositor.CreateSpriteVisual(); + visual.Brush = backdropBrush; + + // Apply to window + ElementCompositionPreview.SetElementChildVisual(window.Content, visual); + } + + public void Dispose() + { + _brush?.Dispose(); + } +} +``` + +### Option 4: SetWindowCompositionAttribute (Windows 11) + +This is what many modern apps use for Acrylic/Mica effects: + +```csharp +[DllImport("user32.dll")] +private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); + +[StructLayout(LayoutKind.Sequential)] +private struct WindowCompositionAttributeData +{ + public WindowCompositionAttribute Attribute; + public IntPtr Data; + public int SizeOfData; +} + +private enum WindowCompositionAttribute +{ + WCA_ACCENT_POLICY = 19 +} + +[StructLayout(LayoutKind.Sequential)] +private struct AccentPolicy +{ + public AccentState AccentState; + public int AccentFlags; + public int GradientColor; + public int AnimationId; +} + +private enum AccentState +{ + ACCENT_DISABLED = 0, + ACCENT_ENABLE_GRADIENT = 1, + ACCENT_ENABLE_TRANSPARENTGRADIENT = 2, + ACCENT_ENABLE_BLURBEHIND = 3, + ACCENT_ENABLE_ACRYLICBLURBEHIND = 4, + ACCENT_ENABLE_HOSTBACKDROP = 5 +} +``` + +## The Reality Check + +**True 100% transparency** (showing the exact desktop/windows behind with no blur) is **not officially supported** in modern Windows apps for security reasons. DWMBlurGlass works because it: + +1. Injects into DWM.exe itself (system-level access) +2. Modifies rendering behavior at the compositor level +3. Requires elevated privileges + +For a regular app, your best options are: + +- **Acrylic backdrop** (translucent blur) +- **Mica backdrop** (tinted transparency) +- **Custom DWM API calls** for maximum transparency within security constraints diff --git a/Logic/Config/FeatureFlags.cs b/Logic/Config/FeatureFlags.cs new file mode 100644 index 0000000..0c7d1ae --- /dev/null +++ b/Logic/Config/FeatureFlags.cs @@ -0,0 +1,6 @@ +namespace LunaDraw.Logic.Config; + +public static class FeatureFlags +{ + public static bool EnableTransparentBackground { get; set; } = false; +} diff --git a/Logic/Extensions/SkiaSharpExtensions.cs b/Logic/Extensions/SkiaSharpExtensions.cs index a0671dd..88f2097 100644 --- a/Logic/Extensions/SkiaSharpExtensions.cs +++ b/Logic/Extensions/SkiaSharpExtensions.cs @@ -23,184 +23,183 @@ using SkiaSharp; -namespace LunaDraw.Logic.Extensions +namespace LunaDraw.Logic.Extensions; + +public static class SkiaSharpExtensions { - public static class SkiaSharpExtensions + public static int GetAlphaPixelCount(this SKPixmap pixmap) { - public static int GetAlphaPixelCount(this SKPixmap pixmap) - { - return GetAlphaPixelCounts(pixmap)[0]; - } + return GetAlphaPixelCounts(pixmap)[0]; + } - public static SKRect AspectFitFill(this SKRect bounds, int width, int height) => - width < height - ? bounds.AspectFit(new SKSize(width, height)) - : bounds.AspectFill(new SKSize(width, height)); + public static SKRect AspectFitFill(this SKRect bounds, int width, int height) => + width < height + ? bounds.AspectFit(new SKSize(width, height)) + : bounds.AspectFill(new SKSize(width, height)); - public static SKPoint MapToInversePoint(this SKMatrix matrix, SKPoint point) + public static SKPoint MapToInversePoint(this SKMatrix matrix, SKPoint point) + { + if (matrix.TryInvert(out var inverseMatrix)) { - if (matrix.TryInvert(out var inverseMatrix)) - { - var transformedPoint = inverseMatrix.MapPoint(point); - return transformedPoint; - } - - return point; + var transformedPoint = inverseMatrix.MapPoint(point); + return transformedPoint; } - public static int[] GetAlphaPixelCounts(params SKPixmap[] pixmaps) - { - var totalAlphaPixels = (int[])Array.CreateInstance(typeof(int), pixmaps.Length); + return point; + } - for (var xPos = 0; xPos < pixmaps[0].Width; xPos++) - for (var yPos = 0; yPos < pixmaps[0].Height; yPos++) - for (var pixmapCount = 0; pixmapCount < pixmaps.Length; pixmapCount++) - totalAlphaPixels[pixmapCount] += pixmaps[pixmapCount].GetPixelColor(xPos, yPos).Alpha > 0 ? 1 : 0; + public static int[] GetAlphaPixelCounts(params SKPixmap[] pixmaps) + { + var totalAlphaPixels = (int[])Array.CreateInstance(typeof(int), pixmaps.Length); - return totalAlphaPixels; - } + for (var xPos = 0; xPos < pixmaps[0].Width; xPos++) + for (var yPos = 0; yPos < pixmaps[0].Height; yPos++) + for (var pixmapCount = 0; pixmapCount < pixmaps.Length; pixmapCount++) + totalAlphaPixels[pixmapCount] += pixmaps[pixmapCount].GetPixelColor(xPos, yPos).Alpha > 0 ? 1 : 0; - public static SKPaint AsOpacity(this SKPaint originalPaint, byte opacity = 50) - { - var opacityPaint = originalPaint.Clone(); - opacityPaint.Color = new SKColor(opacityPaint.Color.Red, opacityPaint.Color.Green, opacityPaint.Color.Blue, opacity); + return totalAlphaPixels; + } - return opacityPaint; - } + public static SKPaint AsOpacity(this SKPaint originalPaint, byte opacity = 50) + { + var opacityPaint = originalPaint.Clone(); + opacityPaint.Color = new SKColor(opacityPaint.Color.Red, opacityPaint.Color.Green, opacityPaint.Color.Blue, opacity); - public static SKImage FlipHorizontal(this SKImage image) - { - if (image?.Encode()?.AsStream() is Stream imageStream) - { - var headerSize = 8; - //var imageDataFlipped = ReadFully2(imageStream, image.Height, 4); - var buffer = new byte[imageStream.Length]; - imageStream.ReadExactly(buffer, 0, (int)imageStream.Length); - var imageDataFlipped = FlipBytesHorizontal(4, buffer.Skip(headerSize).ToArray()); - - return SKImage.FromEncodedData(buffer.Take(headerSize).Concat(imageDataFlipped).ToArray()); - } - - return default!; - } + return opacityPaint; + } - private static byte[] FlipBytesVertical(int size, byte[] inputArray) + public static SKImage FlipHorizontal(this SKImage image) + { + if (image?.Encode()?.AsStream() is Stream imageStream) { - byte[] reversedArray = new byte[inputArray.Length]; + var headerSize = 8; + //var imageDataFlipped = ReadFully2(imageStream, image.Height, 4); + var buffer = new byte[imageStream.Length]; + imageStream.ReadExactly(buffer, 0, (int)imageStream.Length); + var imageDataFlipped = FlipBytesHorizontal(4, buffer.Skip(headerSize).ToArray()); - for (int i = 0; i < inputArray.Length / size; i++) - { - Array.Copy(inputArray, reversedArray.Length - (i + 1) * size, reversedArray, i * size, size); - } - - return reversedArray; + return SKImage.FromEncodedData(buffer.Take(headerSize).Concat(imageDataFlipped).ToArray()); } - private static byte[] FlipBytesHorizontal(int size, byte[] inputArray) - { - byte[] reversedArray = new byte[inputArray.Length]; + return default!; + } - for (int i = 0; i < inputArray.Length / size; i++) - for (int j = 0; j < size; j++) - reversedArray[i * size + j] = inputArray[(i + 1) * size - j - 1]; + private static byte[] FlipBytesVertical(int size, byte[] inputArray) + { + byte[] reversedArray = new byte[inputArray.Length]; - return reversedArray; + for (int i = 0; i < inputArray.Length / size; i++) + { + Array.Copy(inputArray, reversedArray.Length - (i + 1) * size, reversedArray, i * size, size); } - public static byte[] ReadFully2(Stream stream, int rowCount, int bytesPerRow) - { - byte[] imageInfo = new byte[rowCount * bytesPerRow]; + return reversedArray; + } - int i = (rowCount - 1) * bytesPerRow; // get the index of the last row in the image + private static byte[] FlipBytesHorizontal(int size, byte[] inputArray) + { + byte[] reversedArray = new byte[inputArray.Length]; - while (i >= 0) - { - stream.ReadExactly(imageInfo, i, bytesPerRow); - i -= bytesPerRow; - } + for (int i = 0; i < inputArray.Length / size; i++) + for (int j = 0; j < size; j++) + reversedArray[i * size + j] = inputArray[(i + 1) * size - j - 1]; - return imageInfo; - } + return reversedArray; + } - private static byte[] ReverseFrameInPlace2(int stride, byte[] framePixels) - { - var reversedFramePixels = new byte[framePixels.Length]; - var lines = framePixels.Length / stride; + public static byte[] ReadFully2(Stream stream, int rowCount, int bytesPerRow) + { + byte[] imageInfo = new byte[rowCount * bytesPerRow]; - for (var line = 0; line < lines; line++) - { - Array.Copy(framePixels, framePixels.Length - ((line + 1) * stride), reversedFramePixels, line * stride, stride); - } + int i = (rowCount - 1) * bytesPerRow; // get the index of the last row in the image - return reversedFramePixels; + while (i >= 0) + { + stream.ReadExactly(imageInfo, i, bytesPerRow); + i -= bytesPerRow; } - public static float GetRotationDegrees(this SKMatrix matrix) + return imageInfo; + } + + private static byte[] ReverseFrameInPlace2(int stride, byte[] framePixels) + { + var reversedFramePixels = new byte[framePixels.Length]; + var lines = framePixels.Length / stride; + + for (var line = 0; line < lines; line++) { - // SkewY is sin(angle) * scale, ScaleX is cos(angle) * scale - float rotationRadians = (float)Math.Atan2(matrix.SkewY, matrix.ScaleX); - return rotationRadians * 180f / (float)Math.PI; + Array.Copy(framePixels, framePixels.Length - ((line + 1) * stride), reversedFramePixels, line * stride, stride); } - public static (SKMatrix Transform, SKRect Bounds) CalculateRotatedBounds( - this SKMatrix canvasMatrix, - SKPoint startPoint, - SKPoint currentPoint) - { - // Calculate rotation from CanvasMatrix - float rotationDegrees = canvasMatrix.GetRotationDegrees(); + return reversedFramePixels; + } - // Create alignment matrices - var toAligned = SKMatrix.CreateRotationDegrees(rotationDegrees); - var toWorld = SKMatrix.CreateRotationDegrees(-rotationDegrees); + public static float GetRotationDegrees(this SKMatrix matrix) + { + // SkewY is sin(angle) * scale, ScaleX is cos(angle) * scale + float rotationRadians = (float)Math.Atan2(matrix.SkewY, matrix.ScaleX); + return rotationRadians * 180f / (float)Math.PI; + } - var p1 = toAligned.MapPoint(startPoint); - var p2 = toAligned.MapPoint(currentPoint); + public static (SKMatrix Transform, SKRect Bounds) CalculateRotatedBounds( + this SKMatrix canvasMatrix, + SKPoint startPoint, + SKPoint currentPoint) + { + // Calculate rotation from CanvasMatrix + float rotationDegrees = canvasMatrix.GetRotationDegrees(); - var left = Math.Min(p1.X, p2.X); - var top = Math.Min(p1.Y, p2.Y); - var right = Math.Max(p1.X, p2.X); - var bottom = Math.Max(p1.Y, p2.Y); + // Create alignment matrices + var toAligned = SKMatrix.CreateRotationDegrees(rotationDegrees); + var toWorld = SKMatrix.CreateRotationDegrees(-rotationDegrees); - var width = right - left; - var height = bottom - top; + var p1 = toAligned.MapPoint(startPoint); + var p2 = toAligned.MapPoint(currentPoint); - // The Top-Left corner in aligned space - var alignedTL = new SKPoint(left, top); + var left = Math.Min(p1.X, p2.X); + var top = Math.Min(p1.Y, p2.Y); + var right = Math.Max(p1.X, p2.X); + var bottom = Math.Max(p1.Y, p2.Y); - // Transform aligned Top-Left back to World space - var worldTL = toWorld.MapPoint(alignedTL); + var width = right - left; + var height = bottom - top; - // Assemble transform: Translate to World TL, then Rotate by -Degrees (which matches toWorld) - var translation = SKMatrix.CreateTranslation(worldTL.X, worldTL.Y); + // The Top-Left corner in aligned space + var alignedTL = new SKPoint(left, top); - var transformMatrix = SKMatrix.Concat(translation, toWorld); - var bounds = new SKRect(0, 0, width, height); + // Transform aligned Top-Left back to World space + var worldTL = toWorld.MapPoint(alignedTL); - return (transformMatrix, bounds); - } + // Assemble transform: Translate to World TL, then Rotate by -Degrees (which matches toWorld) + var translation = SKMatrix.CreateTranslation(worldTL.X, worldTL.Y); - public static SKMatrix MaxScaleCentered(this SKCanvas canvas, - int width, - int height, - SKRect bounds, - float imageX = 0, - float imageY = 0, - float imageScale = 1) - { - canvas.Translate(width / 2f, height / 2f); + var transformMatrix = SKMatrix.Concat(translation, toWorld); + var bounds = new SKRect(0, 0, width, height); - var ratio = bounds.Width < bounds.Height - ? height / bounds.Height - : width / bounds.Width; + return (transformMatrix, bounds); + } - canvas.Scale(ratio); - canvas.Translate(-bounds.MidX + imageX, -bounds.MidY + imageY); + public static SKMatrix MaxScaleCentered(this SKCanvas canvas, + int width, + int height, + SKRect bounds, + float imageX = 0, + float imageY = 0, + float imageScale = 1) + { + canvas.Translate(width / 2f, height / 2f); - if (imageScale != 1) - canvas.Scale(imageScale); + var ratio = bounds.Width < bounds.Height + ? height / bounds.Height + : width / bounds.Width; - return canvas.TotalMatrix; - } + canvas.Scale(ratio); + canvas.Translate(-bounds.MidX + imageX, -bounds.MidY + imageY); + + if (imageScale != 1) + canvas.Scale(imageScale); + + return canvas.TotalMatrix; } } diff --git a/Logic/Handlers/CanvasInputHandler.cs b/Logic/Handlers/CanvasInputHandler.cs index 5169cd5..1f130c9 100644 --- a/Logic/Handlers/CanvasInputHandler.cs +++ b/Logic/Handlers/CanvasInputHandler.cs @@ -21,7 +21,7 @@ * */ -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using LunaDraw.Logic.Tools; @@ -30,330 +30,344 @@ using SkiaSharp; using SkiaSharp.Views.Maui; -namespace LunaDraw.Logic.Services +namespace LunaDraw.Logic.Services; + +public class CanvasInputHandler( + ToolbarViewModel toolbarViewModel, + ILayerFacade layerFacade, + SelectionObserver selectionObserver, + NavigationModel navigationModel, + IMessageBus messageBus) : ICanvasInputHandler { - public class CanvasInputHandler( - ToolbarViewModel toolbarViewModel, - ILayerFacade layerFacade, - SelectionObserver selectionObserver, - NavigationModel navigationModel, - IMessageBus messageBus) : ICanvasInputHandler + private readonly ToolbarViewModel toolbarViewModel = toolbarViewModel; + private readonly ILayerFacade layerFacade = layerFacade; + private readonly SelectionObserver selectionObserver = selectionObserver; + private readonly NavigationModel navigationModel = navigationModel; + private readonly IMessageBus messageBus = messageBus; + + private readonly Dictionary activeTouches = []; + private long[]? gestureFingerIds; + private bool isMultiTouch = false; + private bool manipulatingSelection = false; + + // Gesture state + private SKPoint startCentroid; + private float startDistance; + private float startAngle; + private SKMatrix startMatrix; + private Dictionary startElementMatrices = []; + + // Smoothing + private SKMatrix previousOutputMatrix = SKMatrix.CreateIdentity(); + private Dictionary previousElementMatrices = []; + private const float SmoothingFactor = 0.1f; // Lower = more smoothing (0.3 was original) + + public void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort) { - private readonly ToolbarViewModel toolbarViewModel = toolbarViewModel; - private readonly ILayerFacade layerFacade = layerFacade; - private readonly SelectionObserver selectionObserver = selectionObserver; - private readonly NavigationModel navigationModel = navigationModel; - private readonly IMessageBus messageBus = messageBus; - - private readonly Dictionary activeTouches = []; - private bool isMultiTouch = false; - private bool manipulatingSelection = false; - - // Gesture state - private SKPoint startCentroid; - private float startDistance; - private float startAngle; - private SKMatrix startMatrix; - private Dictionary startElementMatrices = []; - - // Smoothing - private SKMatrix previousOutputMatrix = SKMatrix.CreateIdentity(); - private Dictionary previousElementMatrices = []; - private const float SmoothingFactor = 0.1f; // Lower = more smoothing (0.3 was original) - - public void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort) - { - if (layerFacade.CurrentLayer == null) return; + if (layerFacade.CurrentLayer == null) return; - var location = e.Location; + var location = e.Location; - // Right click = select - if (e.MouseButton == SKMouseButton.Right) + // Right click = select + if (e.MouseButton == SKMouseButton.Right) + { + if (e.ActionType == SKTouchAction.Pressed) { - if (e.ActionType == SKTouchAction.Pressed) - { - var selectTool = toolbarViewModel.AvailableTools.FirstOrDefault(t => t.Type == ToolType.Select); - if (selectTool != null) toolbarViewModel.ActiveTool = selectTool; + var selectTool = toolbarViewModel.AvailableTools.FirstOrDefault(t => t.Type == ToolType.Select); + if (selectTool != null) toolbarViewModel.ActiveTool = selectTool; - if (navigationModel.ViewMatrix.TryInvert(out var inverse)) - { - PerformContextSelection(inverse.MapPoint(location)); - } + if (navigationModel.ViewMatrix.TryInvert(out var inverse)) + { + PerformContextSelection(inverse.MapPoint(location)); } - return; } + return; + } + + // Track touches + switch (e.ActionType) + { + case SKTouchAction.Pressed: + activeTouches[e.Id] = location; + break; + case SKTouchAction.Released: + case SKTouchAction.Cancelled: + activeTouches.Remove(e.Id); + break; + case SKTouchAction.Moved: + activeTouches[e.Id] = location; + break; + } - // Track touches - switch (e.ActionType) + // Multi-touch state management + if (activeTouches.Count >= 2) + { + if (isMultiTouch && gestureFingerIds != null) { - case SKTouchAction.Pressed: - activeTouches[e.Id] = location; - break; - case SKTouchAction.Released: - case SKTouchAction.Cancelled: - activeTouches.Remove(e.Id); - break; - case SKTouchAction.Moved: - activeTouches[e.Id] = location; - break; + if (!activeTouches.ContainsKey(gestureFingerIds[0]) || !activeTouches.ContainsKey(gestureFingerIds[1])) + { + isMultiTouch = false; + } } - // Multi-touch state management - if (activeTouches.Count >= 2) + if (!isMultiTouch) { - if (!isMultiTouch) + isMultiTouch = true; + gestureFingerIds = activeTouches.Keys.Take(2).ToArray(); + + // Cancel drawing + if (toolbarViewModel.ActiveTool is IDrawingTool tool) { - isMultiTouch = true; + tool.OnTouchCancelled(CreateToolContext()); + } - // Cancel drawing - if (toolbarViewModel.ActiveTool is IDrawingTool tool) - { - tool.OnTouchCancelled(CreateToolContext()); - } + // Snapshot state + var touches = new[] { activeTouches[gestureFingerIds[0]], activeTouches[gestureFingerIds[1]] }; + startCentroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); + startDistance = Distance(touches[0], touches[1]); + startAngle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); + startMatrix = navigationModel.ViewMatrix; + previousOutputMatrix = navigationModel.ViewMatrix; - // Snapshot state - var touches = activeTouches.OrderBy(kvp => kvp.Key).Take(2).Select(kvp => kvp.Value).ToArray(); - startCentroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); - startDistance = Distance(touches[0], touches[1]); - startAngle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); - startMatrix = navigationModel.ViewMatrix; - previousOutputMatrix = navigationModel.ViewMatrix; - - // Check if manipulating selection - manipulatingSelection = false; - if (layerFacade.CurrentLayer?.IsLocked == false && selectionObserver.Selected.Any()) + // Check if manipulating selection + manipulatingSelection = false; + if (toolbarViewModel.ActiveTool.Type == ToolType.Select && + layerFacade.CurrentLayer?.IsLocked == false && + selectionObserver.Selected.Any()) + { + if (navigationModel.ViewMatrix.TryInvert(out var inv)) { - if (navigationModel.ViewMatrix.TryInvert(out var inv)) + foreach (var touch in activeTouches.Values) { - foreach (var touch in activeTouches.Values) + var worldPt = inv.MapPoint(touch); + if (selectionObserver.Selected.Any(el => el.HitTest(worldPt))) { - var worldPt = inv.MapPoint(touch); - if (selectionObserver.Selected.Any(el => el.HitTest(worldPt))) - { - manipulatingSelection = true; - startElementMatrices = selectionObserver.Selected.ToDictionary(el => el, el => el.TransformMatrix); - break; - } + manipulatingSelection = true; + startElementMatrices = selectionObserver.Selected.ToDictionary(el => el, el => el.TransformMatrix); + break; } } } } + } - // Handle multi-touch ONCE after all touch updates - if (e.ActionType == SKTouchAction.Moved) - { - HandleMultiTouch(); - } + // Handle multi-touch ONCE after all touch updates + if (e.ActionType == SKTouchAction.Moved) + { + HandleMultiTouch(); } - else + } + else + { + isMultiTouch = false; + gestureFingerIds = null; + manipulatingSelection = false; + startElementMatrices.Clear(); + previousOutputMatrix = SKMatrix.CreateIdentity(); + + // Single touch + if (navigationModel.ViewMatrix.TryInvert(out var inverse)) { - isMultiTouch = false; - manipulatingSelection = false; - startElementMatrices.Clear(); - previousOutputMatrix = SKMatrix.CreateIdentity(); + var worldPoint = inverse.MapPoint(location); + var context = CreateToolContext(); - // Single touch - if (navigationModel.ViewMatrix.TryInvert(out var inverse)) + switch (e.ActionType) { - var worldPoint = inverse.MapPoint(location); - var context = CreateToolContext(); - - switch (e.ActionType) - { - case SKTouchAction.Pressed: - HandleTouchPressed(worldPoint, context); - break; - case SKTouchAction.Moved: - toolbarViewModel.ActiveTool.OnTouchMoved(worldPoint, context); - break; - case SKTouchAction.Released: - toolbarViewModel.ActiveTool.OnTouchReleased(worldPoint, context); - break; - } + case SKTouchAction.Pressed: + HandleTouchPressed(worldPoint, context); + break; + case SKTouchAction.Moved: + toolbarViewModel.ActiveTool.OnTouchMoved(worldPoint, context); + break; + case SKTouchAction.Released: + toolbarViewModel.ActiveTool.OnTouchReleased(worldPoint, context); + break; } } } + } + + private void HandleMultiTouch() + { + if (gestureFingerIds == null || gestureFingerIds.Length < 2) return; + if (!activeTouches.ContainsKey(gestureFingerIds[0]) || !activeTouches.ContainsKey(gestureFingerIds[1])) return; + + var touches = new[] { activeTouches[gestureFingerIds[0]], activeTouches[gestureFingerIds[1]] }; + + // Current centroid (average of both fingers) + var centroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); - private void HandleMultiTouch() + // Calculate current gesture state + float distance = Distance(touches[0], touches[1]); + float angle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); + + // Calculate transform from start + var translation = centroid - startCentroid; + float scale = startDistance > 0.001f ? distance / startDistance : 1.0f; + float rotation = angle - startAngle; + + // Aggressive deadzones to filter out noise + if (Math.Abs(scale - 1.0f) < 0.05f) scale = 1.0f; + if (Math.Abs(rotation) < 0.2f) rotation = 0f; // ~11 degrees + + // Build transform around start centroid + var transform = SKMatrix.CreateIdentity(); + transform = transform.PostConcat(SKMatrix.CreateTranslation(-startCentroid.X, -startCentroid.Y)); + transform = transform.PostConcat(SKMatrix.CreateScale(scale, scale)); + transform = transform.PostConcat(SKMatrix.CreateRotation(rotation)); + transform = transform.PostConcat(SKMatrix.CreateTranslation(startCentroid.X, startCentroid.Y)); + transform = transform.PostConcat(SKMatrix.CreateTranslation(translation.X, translation.Y)); + + if (manipulatingSelection) { - var touches = activeTouches.OrderBy(kvp => kvp.Key).Take(2).Select(kvp => kvp.Value).ToArray(); - if (touches.Length < 2) return; - - // Current centroid (average of both fingers) - var centroid = new SKPoint((touches[0].X + touches[1].X) / 2f, (touches[0].Y + touches[1].Y) / 2f); - - // Calculate current gesture state - float distance = Distance(touches[0], touches[1]); - float angle = (float)Math.Atan2(touches[1].Y - touches[0].Y, touches[1].X - touches[0].X); - - // Calculate transform from start - var translation = centroid - startCentroid; - float scale = startDistance > 0.001f ? distance / startDistance : 1.0f; - float rotation = angle - startAngle; - - // Aggressive deadzones to filter out noise - if (Math.Abs(scale - 1.0f) < 0.05f) scale = 1.0f; - if (Math.Abs(rotation) < 0.2f) rotation = 0f; // ~11 degrees - - // Build transform around start centroid - var transform = SKMatrix.CreateIdentity(); - transform = transform.PostConcat(SKMatrix.CreateTranslation(-startCentroid.X, -startCentroid.Y)); - transform = transform.PostConcat(SKMatrix.CreateScale(scale, scale)); - transform = transform.PostConcat(SKMatrix.CreateRotation(rotation)); - transform = transform.PostConcat(SKMatrix.CreateTranslation(startCentroid.X, startCentroid.Y)); - transform = transform.PostConcat(SKMatrix.CreateTranslation(translation.X, translation.Y)); - - if (manipulatingSelection) + if (navigationModel.ViewMatrix.TryInvert(out var invView)) { - if (navigationModel.ViewMatrix.TryInvert(out var invView)) - { - var worldTransform = SKMatrix.Concat(invView, SKMatrix.Concat(transform, navigationModel.ViewMatrix)); + var worldTransform = SKMatrix.Concat(invView, SKMatrix.Concat(transform, navigationModel.ViewMatrix)); - foreach (var element in selectionObserver.Selected) + foreach (var element in selectionObserver.Selected) + { + if (startElementMatrices.TryGetValue(element, out var startMat)) { - if (startElementMatrices.TryGetValue(element, out var startMat)) - { - var elementTarget = SKMatrix.Concat(worldTransform, startMat); - - // Smooth element transforms too - if (!previousElementMatrices.ContainsKey(element)) - { - previousElementMatrices[element] = element.TransformMatrix; - } + var elementTarget = SKMatrix.Concat(worldTransform, startMat); - var smoothedElementMatrix = LerpMatrix(previousElementMatrices[element], elementTarget, SmoothingFactor); - element.TransformMatrix = smoothedElementMatrix; - previousElementMatrices[element] = smoothedElementMatrix; + // Smooth element transforms too + if (!previousElementMatrices.ContainsKey(element)) + { + previousElementMatrices[element] = element.TransformMatrix; } + + var smoothedElementMatrix = LerpMatrix(previousElementMatrices[element], elementTarget, SmoothingFactor); + element.TransformMatrix = smoothedElementMatrix; + previousElementMatrices[element] = smoothedElementMatrix; } } } - else - { - // Calculate target matrix - var targetMatrix = SKMatrix.Concat(transform, startMatrix); - - // Smooth the output using exponential moving average - var smoothedMatrix = LerpMatrix(previousOutputMatrix, targetMatrix, SmoothingFactor); - previousOutputMatrix = smoothedMatrix; + } + else + { + // Calculate target matrix + var targetMatrix = SKMatrix.Concat(transform, startMatrix); - navigationModel.ViewMatrix = smoothedMatrix; - } + // Smooth the output using exponential moving average + var smoothedMatrix = LerpMatrix(previousOutputMatrix, targetMatrix, SmoothingFactor); + previousOutputMatrix = smoothedMatrix; - messageBus.SendMessage(new CanvasInvalidateMessage()); + navigationModel.ViewMatrix = smoothedMatrix; } - private SKMatrix LerpMatrix(SKMatrix a, SKMatrix b, float t) - { - return new SKMatrix - { - ScaleX = a.ScaleX + (b.ScaleX - a.ScaleX) * t, - ScaleY = a.ScaleY + (b.ScaleY - a.ScaleY) * t, - SkewX = a.SkewX + (b.SkewX - a.SkewX) * t, - SkewY = a.SkewY + (b.SkewY - a.SkewY) * t, - TransX = a.TransX + (b.TransX - a.TransX) * t, - TransY = a.TransY + (b.TransY - a.TransY) * t, - Persp0 = a.Persp0 + (b.Persp0 - a.Persp0) * t, - Persp1 = a.Persp1 + (b.Persp1 - a.Persp1) * t, - Persp2 = a.Persp2 + (b.Persp2 - a.Persp2) * t - }; - } + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - private void PerformContextSelection(SKPoint worldPoint) + private SKMatrix LerpMatrix(SKMatrix a, SKMatrix b, float t) + { + return new SKMatrix { - IDrawableElement? hit = null; - Layer? hitLayer = null; + ScaleX = a.ScaleX + (b.ScaleX - a.ScaleX) * t, + ScaleY = a.ScaleY + (b.ScaleY - a.ScaleY) * t, + SkewX = a.SkewX + (b.SkewX - a.SkewX) * t, + SkewY = a.SkewY + (b.SkewY - a.SkewY) * t, + TransX = a.TransX + (b.TransX - a.TransX) * t, + TransY = a.TransY + (b.TransY - a.TransY) * t, + Persp0 = a.Persp0 + (b.Persp0 - a.Persp0) * t, + Persp1 = a.Persp1 + (b.Persp1 - a.Persp1) * t, + Persp2 = a.Persp2 + (b.Persp2 - a.Persp2) * t + }; + } - foreach (var layer in layerFacade.Layers.Reverse()) - { - if (!layer.IsVisible || layer.IsLocked) continue; + private void PerformContextSelection(SKPoint worldPoint) + { + IDrawableElement? hit = null; + Layer? hitLayer = null; - hit = layer.Elements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(worldPoint)); + foreach (var layer in layerFacade.Layers.Reverse()) + { + if (!layer.IsVisible || layer.IsLocked) continue; - if (hit != null) - { - hitLayer = layer; - break; - } - } + hit = layer.Elements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(worldPoint)); if (hit != null) { - if (!selectionObserver.Contains(hit)) - { - selectionObserver.Clear(); - selectionObserver.Add(hit); - } - if (hitLayer != null) layerFacade.CurrentLayer = hitLayer; + hitLayer = layer; + break; } - else + } + + if (hit != null) + { + if (!selectionObserver.Contains(hit)) { selectionObserver.Clear(); + selectionObserver.Add(hit); } - - messageBus.SendMessage(new CanvasInvalidateMessage()); + if (hitLayer != null) layerFacade.CurrentLayer = hitLayer; + } + else + { + selectionObserver.Clear(); } - private void HandleTouchPressed(SKPoint worldPoint, ToolContext context) + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + private void HandleTouchPressed(SKPoint worldPoint, ToolContext context) + { + if (layerFacade.CurrentLayer?.IsLocked == true) return; + + if (toolbarViewModel.ActiveTool.Type == ToolType.Select) { - if (layerFacade.CurrentLayer?.IsLocked == true) return; + toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); - if (toolbarViewModel.ActiveTool.Type == ToolType.Select) + if (selectionObserver.Selected.Count > 0) { - toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); - - if (selectionObserver.Selected.Count > 0) + var layer = layerFacade.Layers.FirstOrDefault(l => l.Elements.Contains(selectionObserver.Selected[0])); + if (layer != null && layer != layerFacade.CurrentLayer) { - var layer = layerFacade.Layers.FirstOrDefault(l => l.Elements.Contains(selectionObserver.Selected[0])); - if (layer != null && layer != layerFacade.CurrentLayer) - { - layerFacade.CurrentLayer = layer; - } + layerFacade.CurrentLayer = layer; } - return; - } - - if (selectionObserver.Selected.Any()) - { - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); } + return; + } - toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); + if (selectionObserver.Selected.Any()) + { + selectionObserver.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } - private float Distance(SKPoint p1, SKPoint p2) => - (float)Math.Sqrt((p2.X - p1.X) * (p2.X - p1.X) + (p2.Y - p1.Y) * (p2.Y - p1.Y)); + toolbarViewModel.ActiveTool.OnTouchPressed(worldPoint, context); + } + + private float Distance(SKPoint p1, SKPoint p2) => + (float)Math.Sqrt((p2.X - p1.X) * (p2.X - p1.X) + (p2.Y - p1.Y) * (p2.Y - p1.Y)); - private ToolContext CreateToolContext() + private ToolContext CreateToolContext() + { + return new ToolContext { - return new ToolContext - { - CurrentLayer = layerFacade.CurrentLayer!, - StrokeColor = toolbarViewModel.StrokeColor, - FillColor = toolbarViewModel.FillColor, - StrokeWidth = toolbarViewModel.StrokeWidth, - Opacity = toolbarViewModel.Opacity, - Flow = toolbarViewModel.Flow, - Spacing = toolbarViewModel.Spacing, - BrushShape = toolbarViewModel.CurrentBrushShape, - AllElements = layerFacade.Layers.SelectMany(l => l.Elements), - Layers = layerFacade.Layers, - SelectionObserver = selectionObserver, - Scale = navigationModel.ViewMatrix.ScaleX, - IsGlowEnabled = toolbarViewModel.IsGlowEnabled, - GlowColor = toolbarViewModel.GlowColor, - GlowRadius = toolbarViewModel.GlowRadius, - IsRainbowEnabled = toolbarViewModel.IsRainbowEnabled, - ScatterRadius = toolbarViewModel.ScatterRadius, - SizeJitter = toolbarViewModel.SizeJitter, - AngleJitter = toolbarViewModel.AngleJitter, - HueJitter = toolbarViewModel.HueJitter, - CanvasMatrix = navigationModel.ViewMatrix - }; - } + CurrentLayer = layerFacade.CurrentLayer!, + StrokeColor = toolbarViewModel.StrokeColor, + FillColor = toolbarViewModel.FillColor, + StrokeWidth = toolbarViewModel.StrokeWidth, + Opacity = toolbarViewModel.Opacity, + Flow = toolbarViewModel.Flow, + Spacing = toolbarViewModel.Spacing, + BrushShape = toolbarViewModel.CurrentBrushShape, + AllElements = layerFacade.Layers.SelectMany(l => l.Elements), + Layers = layerFacade.Layers, + SelectionObserver = selectionObserver, + Scale = navigationModel.ViewMatrix.ScaleX, + IsGlowEnabled = toolbarViewModel.IsGlowEnabled, + GlowColor = toolbarViewModel.GlowColor, + GlowRadius = toolbarViewModel.GlowRadius, + IsRainbowEnabled = toolbarViewModel.IsRainbowEnabled, + ScatterRadius = toolbarViewModel.ScatterRadius, + SizeJitter = toolbarViewModel.SizeJitter, + AngleJitter = toolbarViewModel.AngleJitter, + HueJitter = toolbarViewModel.HueJitter, + CanvasMatrix = navigationModel.ViewMatrix + }; } } \ No newline at end of file diff --git a/Logic/Handlers/ICanvasInputHandler.cs b/Logic/Handlers/ICanvasInputHandler.cs index f6f7a89..fddfb67 100644 --- a/Logic/Handlers/ICanvasInputHandler.cs +++ b/Logic/Handlers/ICanvasInputHandler.cs @@ -26,10 +26,9 @@ // For SKCanvasView -namespace LunaDraw.Logic.Services +namespace LunaDraw.Logic.Services; + +public interface ICanvasInputHandler { - public interface ICanvasInputHandler - { - void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort); - } + void ProcessTouch(SKTouchEventArgs e, SKRect canvasViewPort); } diff --git a/Logic/Messages/BrushSettingsChangedMessage.cs b/Logic/Messages/BrushSettingsChangedMessage.cs index f4f2eec..af5b8a8 100644 --- a/Logic/Messages/BrushSettingsChangedMessage.cs +++ b/Logic/Messages/BrushSettingsChangedMessage.cs @@ -23,42 +23,41 @@ using SkiaSharp; -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent when brush settings (color, transparency) change. - /// - public class BrushSettingsChangedMessage( - SKColor? strokeColor = null, - SKColor? fillColor = null, - byte? transparency = null, - byte? flow = null, - float? spacing = null, - float? strokeWidth = null, - bool? isGlowEnabled = null, - SKColor? glowColor = null, - float? glowRadius = null, - bool? isRainbowEnabled = null, - float? scatterRadius = null, - float? sizeJitter = null, - float? angleJitter = null, - float? hueJitter = null, - bool shouldClearFillColor = false) - { - public SKColor? StrokeColor { get; } = strokeColor; - public SKColor? FillColor { get; } = fillColor; - public byte? Transparency { get; } = transparency; - public byte? Flow { get; } = flow; - public float? Spacing { get; } = spacing; - public float? StrokeWidth { get; } = strokeWidth; - public bool? IsGlowEnabled { get; } = isGlowEnabled; - public SKColor? GlowColor { get; } = glowColor; - public float? GlowRadius { get; } = glowRadius; - public bool? IsRainbowEnabled { get; } = isRainbowEnabled; - public float? ScatterRadius { get; } = scatterRadius; - public float? SizeJitter { get; } = sizeJitter; - public float? AngleJitter { get; } = angleJitter; - public float? HueJitter { get; } = hueJitter; - public bool ShouldClearFillColor { get; } = shouldClearFillColor; - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent when brush settings (color, transparency) change. + /// + public class BrushSettingsChangedMessage( + SKColor? strokeColor = null, + SKColor? fillColor = null, + byte? transparency = null, + byte? flow = null, + float? spacing = null, + float? strokeWidth = null, + bool? isGlowEnabled = null, + SKColor? glowColor = null, + float? glowRadius = null, + bool? isRainbowEnabled = null, + float? scatterRadius = null, + float? sizeJitter = null, + float? angleJitter = null, + float? hueJitter = null, + bool shouldClearFillColor = false) + { + public SKColor? StrokeColor { get; } = strokeColor; + public SKColor? FillColor { get; } = fillColor; + public byte? Transparency { get; } = transparency; + public byte? Flow { get; } = flow; + public float? Spacing { get; } = spacing; + public float? StrokeWidth { get; } = strokeWidth; + public bool? IsGlowEnabled { get; } = isGlowEnabled; + public SKColor? GlowColor { get; } = glowColor; + public float? GlowRadius { get; } = glowRadius; + public bool? IsRainbowEnabled { get; } = isRainbowEnabled; + public float? ScatterRadius { get; } = scatterRadius; + public float? SizeJitter { get; } = sizeJitter; + public float? AngleJitter { get; } = angleJitter; + public float? HueJitter { get; } = hueJitter; + public bool ShouldClearFillColor { get; } = shouldClearFillColor; + } diff --git a/Logic/Messages/BrushShapeChangedMessage.cs b/Logic/Messages/BrushShapeChangedMessage.cs index 69dbd85..57cf612 100644 --- a/Logic/Messages/BrushShapeChangedMessage.cs +++ b/Logic/Messages/BrushShapeChangedMessage.cs @@ -23,10 +23,9 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages -{ - public class BrushShapeChangedMessage(BrushShape shape) - { - public BrushShape Shape { get; } = shape; - } -} +namespace LunaDraw.Logic.Messages; + + public class BrushShapeChangedMessage(BrushShape shape) + { + public BrushShape Shape { get; } = shape; + } diff --git a/Logic/Messages/CanvasInvalidateMessage.cs b/Logic/Messages/CanvasInvalidateMessage.cs index 10e3a0b..2b0ae9c 100644 --- a/Logic/Messages/CanvasInvalidateMessage.cs +++ b/Logic/Messages/CanvasInvalidateMessage.cs @@ -21,13 +21,12 @@ * */ -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent to request the canvas to invalidate and redraw. - /// - public class CanvasInvalidateMessage - { - // No properties needed, just a signal - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent to request the canvas to invalidate and redraw. + /// + public class CanvasInvalidateMessage + { + // No properties needed, just a signal + } diff --git a/Logic/Messages/DrawingStateChangedMessage.cs b/Logic/Messages/DrawingStateChangedMessage.cs index a7bfc02..51344d7 100644 --- a/Logic/Messages/DrawingStateChangedMessage.cs +++ b/Logic/Messages/DrawingStateChangedMessage.cs @@ -21,12 +21,11 @@ * */ -namespace LunaDraw.Logic.Messages -{ - /// - /// A message to indicate that the drawable state of the canvas has changed and a history snapshot should be taken. - /// - public class DrawingStateChangedMessage - { - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// A message to indicate that the drawable state of the canvas has changed and a history snapshot should be taken. + /// + public class DrawingStateChangedMessage + { + } diff --git a/Logic/Messages/ElementAddedMessage.cs b/Logic/Messages/ElementAddedMessage.cs index 0eb6611..40abca2 100644 --- a/Logic/Messages/ElementAddedMessage.cs +++ b/Logic/Messages/ElementAddedMessage.cs @@ -23,14 +23,13 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent when a new element is added to a layer. - /// - public class ElementAddedMessage(IDrawableElement element, Layer targetLayer) - { - public IDrawableElement Element { get; } = element; - public Layer TargetLayer { get; } = targetLayer; - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent when a new element is added to a layer. + /// + public class ElementAddedMessage(IDrawableElement element, Layer targetLayer) + { + public IDrawableElement Element { get; } = element; + public Layer TargetLayer { get; } = targetLayer; + } diff --git a/Logic/Messages/ElementRemovedMessage.cs b/Logic/Messages/ElementRemovedMessage.cs index f48acaf..526611f 100644 --- a/Logic/Messages/ElementRemovedMessage.cs +++ b/Logic/Messages/ElementRemovedMessage.cs @@ -23,14 +23,13 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent when an element is removed from a layer. - /// - public class ElementRemovedMessage(IDrawableElement element, Layer sourceLayer) - { - public IDrawableElement Element { get; } = element; - public Layer SourceLayer { get; } = sourceLayer; - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent when an element is removed from a layer. + /// + public class ElementRemovedMessage(IDrawableElement element, Layer sourceLayer) + { + public IDrawableElement Element { get; } = element; + public Layer SourceLayer { get; } = sourceLayer; + } diff --git a/Logic/Messages/LayerChangedMessage.cs b/Logic/Messages/LayerChangedMessage.cs index 093fd8d..ad6f7e2 100644 --- a/Logic/Messages/LayerChangedMessage.cs +++ b/Logic/Messages/LayerChangedMessage.cs @@ -23,13 +23,12 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent when a layer's properties (e.g., visibility, lock status) change. - /// - public class LayerChangedMessage(Layer changedLayer) - { - public Layer ChangedLayer { get; } = changedLayer; - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent when a layer's properties (e.g., visibility, lock status) change. + /// + public class LayerChangedMessage(Layer changedLayer) + { + public Layer ChangedLayer { get; } = changedLayer; + } diff --git a/Logic/Messages/SelectionChangedMessage.cs b/Logic/Messages/SelectionChangedMessage.cs index fdefcfd..77c157c 100644 --- a/Logic/Messages/SelectionChangedMessage.cs +++ b/Logic/Messages/SelectionChangedMessage.cs @@ -23,13 +23,12 @@ using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent when the selection of elements changes. - /// - public class SelectionChangedMessage(IEnumerable selectedElements) - { - public IEnumerable SelectedElements { get; } = selectedElements; - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent when the selection of elements changes. + /// + public class SelectionChangedMessage(IEnumerable selectedElements) + { + public IEnumerable SelectedElements { get; } = selectedElements; + } diff --git a/Logic/Messages/ToolChangedMessage.cs b/Logic/Messages/ToolChangedMessage.cs index dc2f7a0..9357c5e 100644 --- a/Logic/Messages/ToolChangedMessage.cs +++ b/Logic/Messages/ToolChangedMessage.cs @@ -23,13 +23,12 @@ using LunaDraw.Logic.Tools; -namespace LunaDraw.Logic.Messages -{ - /// - /// Message sent when the active drawing tool changes. - /// - public class ToolChangedMessage(IDrawingTool newTool) - { - public IDrawingTool NewTool { get; } = newTool; - } -} +namespace LunaDraw.Logic.Messages; + + /// + /// Message sent when the active drawing tool changes. + /// + public class ToolChangedMessage(IDrawingTool newTool) + { + public IDrawingTool NewTool { get; } = newTool; + } diff --git a/Logic/Models/BrushShape.cs b/Logic/Models/BrushShape.cs index 8317a56..2279660 100644 --- a/Logic/Models/BrushShape.cs +++ b/Logic/Models/BrushShape.cs @@ -23,170 +23,406 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +public enum BrushShapeType +{ + Circle, + Square, + Star, + Heart, + Sparkle, + Cloud, + Moon, + Lightning, + Diamond, + Triangle, + Hexagon, + Unicorn, + Giraffe, + Bear, + Fireworks, + Flower, + Sun, + Snowflake, + Butterfly, + Fish, + Paw, + Leaf, + MusicNote, + Smile, + Custom +} + +public class BrushShape { - public enum BrushShapeType + public string Name { get; set; } = "Circle"; + public BrushShapeType Type { get; set; } = BrushShapeType.Circle; + public SKPath Path { get; set; } = new SKPath(); + + public static BrushShape Circle() + { + var path = new SKPath(); + path.AddCircle(0, 0, 10); + return new BrushShape { Name = "Circle", Type = BrushShapeType.Circle, Path = path }; + } + + public static BrushShape Square() + { + var path = new SKPath(); + path.AddRect(new SKRect(-10, -10, 10, 10)); + return new BrushShape { Name = "Square", Type = BrushShapeType.Square, Path = path }; + } + + public static BrushShape Star() + { + var starPath = new SKPath(); + starPath.MoveTo(0, -10); + starPath.LineTo(2.5f, -3.5f); + starPath.LineTo(9.5f, -2.5f); + starPath.LineTo(4.5f, 2.5f); + starPath.LineTo(6f, 9.5f); + starPath.LineTo(0, 6.5f); + starPath.LineTo(-6f, 9.5f); + starPath.LineTo(-4.5f, 2.5f); + starPath.LineTo(-9.5f, -2.5f); + starPath.LineTo(-2.5f, -3.5f); + starPath.Close(); + + return new BrushShape { Name = "Star", Type = BrushShapeType.Star, Path = starPath }; + } + + public static BrushShape Heart() + { + var path = new SKPath(); + // Heart shape logic + path.MoveTo(0, 5); + path.CubicTo(0, 5, -10, -5, -5, -10); + path.CubicTo(-2.5f, -12.5f, 0, -7.5f, 0, -2.5f); + path.CubicTo(0, -7.5f, 2.5f, -12.5f, 5, -10); + path.CubicTo(10, -5, 0, 5, 0, 5); + path.Close(); + + // Center it roughly + path.Transform(SKMatrix.CreateTranslation(0, 2.5f)); + + return new BrushShape { Name = "Heart", Type = BrushShapeType.Heart, Path = path }; + } + + public static BrushShape Sparkle() + { + var path = new SKPath(); + // Four-pointed star / sparkle + path.MoveTo(0, -10); + path.QuadTo(1, -1, 10, 0); + path.QuadTo(1, 1, 0, 10); + path.QuadTo(-1, 1, -10, 0); + path.QuadTo(-1, -1, 0, -10); + path.Close(); + + return new BrushShape { Name = "Sparkle", Type = BrushShapeType.Sparkle, Path = path }; + } + + public static BrushShape Cloud() + { + var path = new SKPath(); + path.MoveTo(-8, 0); + path.LineTo(8, 0); + path.ArcTo(new SKRect(4, -8, 12, 0), 0, -180, false); + path.ArcTo(new SKRect(-4, -12, 4, -4), 0, -180, false); + path.ArcTo(new SKRect(-12, -8, -4, 0), 0, -180, false); + path.Close(); + path.Transform(SKMatrix.CreateTranslation(0, 4)); // Center + return new BrushShape { Name = "Cloud", Type = BrushShapeType.Cloud, Path = path }; + } + + public static BrushShape Moon() + { + var path = new SKPath(); + path.AddArc(new SKRect(-10, -10, 10, 10), 30, 300); + // Cut out the inner part + // This is hard with basic paths without path ops (which might be heavy) + // Let's try a simple crescent approximation with two curves + path.Reset(); + path.MoveTo(0, -10); + path.ArcTo(new SKRect(-10, -10, 10, 10), 270, 180, false); + path.ArcTo(new SKRect(-5, -10, 5, 10), 90, -180, false); + path.Close(); + + return new BrushShape { Name = "Moon", Type = BrushShapeType.Moon, Path = path }; + } + + public static BrushShape Lightning() + { + var path = new SKPath(); + path.MoveTo(2, -10); + path.LineTo(-5, 0); + path.LineTo(0, 0); + path.LineTo(-2, 10); + path.LineTo(5, 0); + path.LineTo(0, 0); + path.Close(); + return new BrushShape { Name = "Lightning", Type = BrushShapeType.Lightning, Path = path }; + } + + public static BrushShape Diamond() + { + var path = new SKPath(); + path.MoveTo(0, -10); + path.LineTo(7, 0); + path.LineTo(0, 10); + path.LineTo(-7, 0); + path.Close(); + return new BrushShape { Name = "Diamond", Type = BrushShapeType.Diamond, Path = path }; + } + + public static BrushShape Triangle() + { + var path = new SKPath(); + path.MoveTo(0, -10); + path.LineTo(9, 5); + path.LineTo(-9, 5); + path.Close(); + path.Transform(SKMatrix.CreateTranslation(0, 2)); + return new BrushShape { Name = "Triangle", Type = BrushShapeType.Triangle, Path = path }; + } + + public static BrushShape Hexagon() + { + var path = new SKPath(); + for (int i = 0; i < 6; i++) { - Circle, - Square, - Star, - Heart, - Sparkle, - Cloud, - Moon, - Lightning, - Diamond, - Triangle, - Hexagon, - Custom + float angle = i * 60 * (float)Math.PI / 180; + float x = 10 * (float)Math.Sin(angle); + float y = -10 * (float)Math.Cos(angle); + if (i == 0) path.MoveTo(x, y); + else path.LineTo(x, y); } + path.Close(); + return new BrushShape { Name = "Hexagon", Type = BrushShapeType.Hexagon, Path = path }; + } + + public static BrushShape Unicorn() + { + var path = new SKPath(); + path.MoveTo(-4, 0); + path.LineTo(0, -10); // Horn tip + path.LineTo(2, -2); + path.LineTo(5, 0); // Nose + path.LineTo(4, 5); + path.LineTo(-2, 5); + path.LineTo(-4, 0); + path.Close(); + // Mane + path.MoveTo(-2, -5); + path.QuadTo(-6, -2, -4, 2); + return new BrushShape { Name = "Unicorn", Type = BrushShapeType.Unicorn, Path = path }; + } - public class BrushShape + public static BrushShape Giraffe() + { + var path = new SKPath(); + // Side profile view + path.MoveTo(-4, 8); // Neck base + path.LineTo(-2, -5); // Neck up to head back + + // Ossicones (Horns) + path.LineTo(-3, -11); + path.LineTo(-1, -11); + path.LineTo(-0.5f, -6); + + // Forehead to nose + path.LineTo(1, -5); + path.LineTo(4, 0); // Snout tip + path.LineTo(3, 3); // Jaw + path.LineTo(-1, 2); // Jaw back + path.LineTo(2, 8); // Neck front + path.Close(); + return new BrushShape { Name = "Giraffe", Type = BrushShapeType.Giraffe, Path = path }; + } + + public static BrushShape Bear() + { + var path = new SKPath(); + path.AddCircle(0, 0, 7); // Face + path.AddCircle(-6, -6, 3); // Left Ear + path.AddCircle(6, -6, 3); // Right Ear + return new BrushShape { Name = "Bear", Type = BrushShapeType.Bear, Path = path }; + } + + public static BrushShape Fireworks() + { + var path = new SKPath(); + // A burst of dots/stars instead of lines + path.AddCircle(0, 0, 2); // Center + + for (int i = 0; i < 8; i++) { - public string Name { get; set; } = "Circle"; - public BrushShapeType Type { get; set; } = BrushShapeType.Circle; - public SKPath Path { get; set; } = new SKPath(); + float angle = i * 45 * (float)Math.PI / 180; + float x = 8 * (float)Math.Sin(angle); + float y = -8 * (float)Math.Cos(angle); + + // Outer dots + path.AddCircle(x, y, 1.5f); - public static BrushShape Circle() - { - var path = new SKPath(); - path.AddCircle(0, 0, 10); - return new BrushShape { Name = "Circle", Type = BrushShapeType.Circle, Path = path }; - } - - public static BrushShape Square() - { - var path = new SKPath(); - path.AddRect(new SKRect(-10, -10, 10, 10)); - return new BrushShape { Name = "Square", Type = BrushShapeType.Square, Path = path }; - } - - public static BrushShape Star() - { - var starPath = new SKPath(); - starPath.MoveTo(0, -10); - starPath.LineTo(2.5f, -3.5f); - starPath.LineTo(9.5f, -2.5f); - starPath.LineTo(4.5f, 2.5f); - starPath.LineTo(6f, 9.5f); - starPath.LineTo(0, 6.5f); - starPath.LineTo(-6f, 9.5f); - starPath.LineTo(-4.5f, 2.5f); - starPath.LineTo(-9.5f, -2.5f); - starPath.LineTo(-2.5f, -3.5f); - starPath.Close(); - - return new BrushShape { Name = "Star", Type = BrushShapeType.Star, Path = starPath }; - } - - public static BrushShape Heart() - { - var path = new SKPath(); - // Heart shape logic - path.MoveTo(0, 5); - path.CubicTo(0, 5, -10, -5, -5, -10); - path.CubicTo(-2.5f, -12.5f, 0, -7.5f, 0, -2.5f); - path.CubicTo(0, -7.5f, 2.5f, -12.5f, 5, -10); - path.CubicTo(10, -5, 0, 5, 0, 5); - path.Close(); - - // Center it roughly - path.Transform(SKMatrix.CreateTranslation(0, 2.5f)); - - return new BrushShape { Name = "Heart", Type = BrushShapeType.Heart, Path = path }; - } - - public static BrushShape Sparkle() - { - var path = new SKPath(); - // Four-pointed star / sparkle - path.MoveTo(0, -10); - path.QuadTo(1, -1, 10, 0); - path.QuadTo(1, 1, 0, 10); - path.QuadTo(-1, 1, -10, 0); - path.QuadTo(-1, -1, 0, -10); - path.Close(); - - return new BrushShape { Name = "Sparkle", Type = BrushShapeType.Sparkle, Path = path }; - } - - public static BrushShape Cloud() - { - var path = new SKPath(); - path.MoveTo(-8, 0); - path.LineTo(8, 0); - path.ArcTo(new SKRect(4, -8, 12, 0), 0, -180, false); - path.ArcTo(new SKRect(-4, -12, 4, -4), 0, -180, false); - path.ArcTo(new SKRect(-12, -8, -4, 0), 0, -180, false); - path.Close(); - path.Transform(SKMatrix.CreateTranslation(0, 4)); // Center - return new BrushShape { Name = "Cloud", Type = BrushShapeType.Cloud, Path = path }; - } - - public static BrushShape Moon() - { - var path = new SKPath(); - path.AddArc(new SKRect(-10, -10, 10, 10), 30, 300); - // Cut out the inner part - // This is hard with basic paths without path ops (which might be heavy) - // Let's try a simple crescent approximation with two curves - path.Reset(); - path.MoveTo(0, -10); - path.ArcTo(new SKRect(-10, -10, 10, 10), 270, 180, false); - path.ArcTo(new SKRect(-5, -10, 5, 10), 90, -180, false); - path.Close(); - - return new BrushShape { Name = "Moon", Type = BrushShapeType.Moon, Path = path }; - } - - public static BrushShape Lightning() - { - var path = new SKPath(); - path.MoveTo(2, -10); - path.LineTo(-5, 0); - path.LineTo(0, 0); - path.LineTo(-2, 10); - path.LineTo(5, 0); - path.LineTo(0, 0); - path.Close(); - return new BrushShape { Name = "Lightning", Type = BrushShapeType.Lightning, Path = path }; - } - - public static BrushShape Diamond() - { - var path = new SKPath(); - path.MoveTo(0, -10); - path.LineTo(7, 0); - path.LineTo(0, 10); - path.LineTo(-7, 0); - path.Close(); - return new BrushShape { Name = "Diamond", Type = BrushShapeType.Diamond, Path = path }; - } - - public static BrushShape Triangle() - { - var path = new SKPath(); - path.MoveTo(0, -10); - path.LineTo(9, 5); - path.LineTo(-9, 5); - path.Close(); - path.Transform(SKMatrix.CreateTranslation(0, 2)); - return new BrushShape { Name = "Triangle", Type = BrushShapeType.Triangle, Path = path }; - } - - public static BrushShape Hexagon() - { - var path = new SKPath(); - for (int i = 0; i < 6; i++) - { - float angle = i * 60 * (float)Math.PI / 180; - float x = 10 * (float)Math.Sin(angle); - float y = -10 * (float)Math.Cos(angle); - if (i == 0) path.MoveTo(x, y); - else path.LineTo(x, y); - } - path.Close(); - return new BrushShape { Name = "Hexagon", Type = BrushShapeType.Hexagon, Path = path }; - } + // Middle dots + float xm = 4 * (float)Math.Sin(angle); + float ym = -4 * (float)Math.Cos(angle); + path.AddCircle(xm, ym, 1.0f); } + return new BrushShape { Name = "Fireworks", Type = BrushShapeType.Fireworks, Path = path }; + } + + public static BrushShape Flower() + { + var path = new SKPath(); + path.AddCircle(0, 0, 3); // Center + for (int i = 0; i < 5; i++) + { + float angle = i * 72 * (float)Math.PI / 180; + float cx = 6 * (float)Math.Sin(angle); + float cy = -6 * (float)Math.Cos(angle); + path.AddCircle(cx, cy, 3.5f); + } + return new BrushShape { Name = "Flower", Type = BrushShapeType.Flower, Path = path }; + } + + public static BrushShape Sun() + { + var path = new SKPath(); + path.AddCircle(0, 0, 6); // Core + + // Triangular rays + for (int i = 0; i < 8; i++) + { + float angle = i * 45 * (float)Math.PI / 180; + + // Base of the triangle ray on the circle + float baseAngle1 = angle - (10 * (float)Math.PI / 180); + float baseAngle2 = angle + (10 * (float)Math.PI / 180); + + float x1 = 6 * (float)Math.Sin(baseAngle1); + float y1 = -6 * (float)Math.Cos(baseAngle1); + + float x2 = 6 * (float)Math.Sin(baseAngle2); + float y2 = -6 * (float)Math.Cos(baseAngle2); + + // Tip of the ray + float tipX = 11 * (float)Math.Sin(angle); + float tipY = -11 * (float)Math.Cos(angle); + + path.MoveTo(x1, y1); + path.LineTo(tipX, tipY); + path.LineTo(x2, y2); + path.Close(); + } + return new BrushShape { Name = "Sun", Type = BrushShapeType.Sun, Path = path }; + } + + public static BrushShape Snowflake() + { + var path = new SKPath(); + // Use rectangles for arms so they have width + for (int i = 0; i < 3; i++) // 3 bars crossing make 6 arms + { + path.AddRect(new SKRect(-1.5f, -10, 1.5f, 10)); // Vertical-ish bar + path.Transform(SKMatrix.CreateRotationDegrees(60)); + } + // Add some details on the ends (small diamonds) + var decorativePath = new SKPath(); + for(int i = 0; i < 6; i++) + { + decorativePath.AddCircle(0, -8, 2); + decorativePath.Transform(SKMatrix.CreateRotationDegrees(60)); + } + path.AddPath(decorativePath); + + return new BrushShape { Name = "Snowflake", Type = BrushShapeType.Snowflake, Path = path }; + } + + public static BrushShape Butterfly() + { + var path = new SKPath(); + // Body + path.AddOval(new SKRect(-1, -6, 1, 6)); + // Wings + path.AddOval(new SKRect(-8, -8, -1, 0)); // Top Left + path.AddOval(new SKRect(1, -8, 8, 0)); // Top Right + path.AddOval(new SKRect(-6, 0, -1, 6)); // Bottom Left + path.AddOval(new SKRect(1, 0, 6, 6)); // Bottom Right + return new BrushShape { Name = "Butterfly", Type = BrushShapeType.Butterfly, Path = path }; + } + + public static BrushShape Fish() + { + var path = new SKPath(); + // Body + path.AddOval(new SKRect(-8, -5, 4, 5)); + // Tail + path.MoveTo(4, 0); + path.LineTo(8, -4); + path.LineTo(8, 4); + path.Close(); + return new BrushShape { Name = "Fish", Type = BrushShapeType.Fish, Path = path }; + } + + public static BrushShape Paw() + { + var path = new SKPath(); + // Main pad + path.AddOval(new SKRect(-5, -2, 5, 6)); + // Toes + path.AddCircle(-4, -5, 2); + path.AddCircle(0, -6, 2); + path.AddCircle(4, -5, 2); + return new BrushShape { Name = "Paw", Type = BrushShapeType.Paw, Path = path }; + } + + public static BrushShape Leaf() + { + var path = new SKPath(); + // Wider body + path.MoveTo(0, -10); + path.CubicTo(8, -5, 8, 5, 0, 10); + path.CubicTo(-8, 5, -8, -5, 0, -10); + path.Close(); + return new BrushShape { Name = "Leaf", Type = BrushShapeType.Leaf, Path = path }; + } + + public static BrushShape MusicNote() + { + var path = new SKPath(); + path.AddOval(new SKRect(-4, 4, 2, 8)); // Head + path.MoveTo(1, 6); + path.LineTo(1, -6); // Stem + path.LineTo(5, -4); // Flag + path.LineTo(5, -2); + path.LineTo(1, -4); + path.LineTo(1, 6); // Close back to startish + path.Close(); + return new BrushShape { Name = "MusicNote", Type = BrushShapeType.MusicNote, Path = path }; + } + + public static BrushShape Smile() + { + var path = new SKPath(); + path.FillType = SKPathFillType.EvenOdd; // Ensure holes are subtracted + path.AddCircle(0, 0, 9); // Face + + // Eyes (as holes - simpler with EvenOdd, but let's reverse direction too just in case) + path.AddCircle(-3.5f, -3, 1.5f, SKPathDirection.CounterClockwise); + path.AddCircle(3.5f, -3, 1.5f, SKPathDirection.CounterClockwise); + + // Mouth (Crescent shape) + var mouth = new SKPath(); + mouth.MoveTo(-5, 2); + mouth.QuadTo(0, 7, 5, 2); // Bottom curve + mouth.QuadTo(0, 5, -5, 2); // Top curve + mouth.Close(); + + // Add mouth to main path (it should be treated as a hole if inside and EvenOdd or winding correct) + path.AddPath(mouth); + + return new BrushShape { Name = "Smile", Type = BrushShapeType.Smile, Path = path }; + } } diff --git a/Logic/Models/DrawableEllipse.cs b/Logic/Models/DrawableEllipse.cs index 3b3a352..5bf25e4 100644 --- a/Logic/Models/DrawableEllipse.cs +++ b/Logic/Models/DrawableEllipse.cs @@ -23,187 +23,186 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents an ellipse shape on the canvas. +/// +public class DrawableEllipse : IDrawableElement { - /// - /// Represents an ellipse shape on the canvas. - /// - public class DrawableEllipse : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public SKRect Oval { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(Oval); + + public void Draw(SKCanvas canvas) { - public Guid Id { get; } = Guid.NewGuid(); - public SKRect Oval { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(Oval); - - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; - - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); - - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawOval(Oval, highlightPaint); - } + if (!IsVisible) return; - // Draw glow if enabled - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowPaint = new SKPaint - { - Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawOval(Oval, glowPaint); - } + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); - // Draw fill if specified - if (FillColor.HasValue) - { - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = FillColor.Value.WithAlpha(Opacity), - IsAntialias = true - }; - canvas.DrawOval(Oval, fillPaint); - } - - // Draw stroke - using var strokePaint = new SKPaint + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint { Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, IsAntialias = true }; - canvas.DrawOval(Oval, strokePaint); - - canvas.Restore(); + canvas.DrawOval(Oval, highlightPaint); } - public bool HitTest(SKPoint point) + // Draw glow if enabled + if (IsGlowEnabled && GlowRadius > 0) { - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - - using var path = new SKPath(); - path.AddOval(Oval); - - // Check if filled and point is inside the fill path - if (FillColor.HasValue && path.Contains(localPoint.X, localPoint.Y)) - { - return true; - } - - // Check if point is near the stroke - using var paint = new SKPaint + using var glowPaint = new SKPaint { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 10 // Add tolerance + Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, + Color = GlowColor.WithAlpha(Opacity), + StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) }; - using var strokedPath = new SKPath(); - paint.GetFillPath(path, strokedPath); - - return strokedPath.Contains(localPoint.X, localPoint.Y); + canvas.DrawOval(Oval, glowPaint); } - public IDrawableElement Clone() + // Draw fill if specified + if (FillColor.HasValue) { - return new DrawableEllipse + using var fillPaint = new SKPaint { - Oval = Oval, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius + Style = SKPaintStyle.Fill, + Color = FillColor.Value.WithAlpha(Opacity), + IsAntialias = true }; + canvas.DrawOval(Oval, fillPaint); } - public void Translate(SKPoint offset) + // Draw stroke + using var strokePaint = new SKPaint { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawOval(Oval, strokePaint); + + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + + using var path = new SKPath(); + path.AddOval(Oval); - public void Transform(SKMatrix matrix) + // Check if filled and point is inside the fill path + if (FillColor.HasValue && path.Contains(localPoint.X, localPoint.Y)) { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + return true; } - public SKPath GetPath() + // Check if point is near the stroke + using var paint = new SKPaint { - var path = new SKPath(); - path.AddOval(Oval); + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 10 // Add tolerance + }; + using var strokedPath = new SKPath(); + paint.GetFillPath(path, strokedPath); - if (StrokeWidth > 0) - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - - if (FillColor.HasValue) - { - var combined = new SKPath(); - path.Op(strokePath, SKPathOp.Union, combined); - path.Dispose(); - path = combined; - } - else - { - path.Dispose(); - path = strokePath; - } - } + return strokedPath.Contains(localPoint.X, localPoint.Y); + } - path.Transform(TransformMatrix); - return path; - } + public IDrawableElement Clone() + { + return new DrawableEllipse + { + Oval = Oval, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } + + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + var path = new SKPath(); + path.AddOval(Oval); - public SKPath GetGeometryPath() + if (StrokeWidth > 0) { - var path = new SKPath(); - path.AddOval(Oval); - path.Transform(TransformMatrix); - return path; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); + + if (FillColor.HasValue) + { + var combined = new SKPath(); + path.Op(strokePath, SKPathOp.Union, combined); + path.Dispose(); + path = combined; + } + else + { + path.Dispose(); + path = strokePath; + } } + + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + path.AddOval(Oval); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawableGroup.cs b/Logic/Models/DrawableGroup.cs index 6c2b13f..88e5469 100644 --- a/Logic/Models/DrawableGroup.cs +++ b/Logic/Models/DrawableGroup.cs @@ -23,165 +23,164 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a group of drawable elements that can be manipulated as a single unit. +/// +public class DrawableGroup : IDrawableElement { - /// - /// Represents a group of drawable elements that can be manipulated as a single unit. - /// - public class DrawableGroup : IDrawableElement - { - public Guid Id { get; } = Guid.NewGuid(); - public List Children { get; } = []; - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + public Guid Id { get; } = Guid.NewGuid(); + public List Children { get; } = []; + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - public bool IsVisible { get; set; } = true; - private bool isSelected; - public bool IsSelected + public bool IsVisible { get; set; } = true; + private bool isSelected; + public bool IsSelected + { + get => isSelected; + set { - get => isSelected; - set + if (isSelected == value) return; + isSelected = value; + foreach (var child in Children) { - if (isSelected == value) return; - isSelected = value; - foreach (var child in Children) - { - child.IsSelected = value; - } + child.IsSelected = value; } } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } // Not directly used - public SKColor StrokeColor { get; set; } // Not directly used - public float StrokeWidth { get; set; } // Not directly used + } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } // Not directly used + public SKColor StrokeColor { get; set; } // Not directly used + public float StrokeWidth { get; set; } // Not directly used - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; - public SKRect Bounds + public SKRect Bounds + { + get { - get - { - if (!Children.Any()) return SKRect.Empty; + if (!Children.Any()) return SKRect.Empty; - var left = Children.Min(c => c.Bounds.Left); - var top = Children.Min(c => c.Bounds.Top); - var right = Children.Max(c => c.Bounds.Right); - var bottom = Children.Max(c => c.Bounds.Bottom); + var left = Children.Min(c => c.Bounds.Left); + var top = Children.Min(c => c.Bounds.Top); + var right = Children.Max(c => c.Bounds.Right); + var bottom = Children.Max(c => c.Bounds.Bottom); - return new SKRect(left, top, right, bottom); - } + return new SKRect(left, top, right, bottom); } + } - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; - - // Check if isolation is needed (if any child uses Clear blend mode) - var needsIsolation = Children.OfType().Any(dp => dp.BlendMode == SKBlendMode.Clear); - - if (needsIsolation) - { - using var paint = new SKPaint { Color = SKColors.White.WithAlpha(Opacity) }; - canvas.SaveLayer(paint); - } + public void Draw(SKCanvas canvas) + { + if (!IsVisible) return; - // The group's transform is applied to children, not to the canvas here - foreach (var child in Children) - { - child.Draw(canvas); - } + // Check if isolation is needed (if any child uses Clear blend mode) + var needsIsolation = Children.OfType().Any(dp => dp.BlendMode == SKBlendMode.Clear); - if (needsIsolation) - { - canvas.Restore(); - } + if (needsIsolation) + { + using var paint = new SKPaint { Color = SKColors.White.WithAlpha(Opacity) }; + canvas.SaveLayer(paint); } - public bool HitTest(SKPoint point) + // The group's transform is applied to children, not to the canvas here + foreach (var child in Children) { - return Children.Any(child => child.HitTest(point)); + child.Draw(canvas); } - public IDrawableElement Clone() + if (needsIsolation) { - var newGroup = new DrawableGroup - { - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius - }; - foreach (var child in Children) - { - newGroup.Children.Add(child.Clone()); - } - return newGroup; + canvas.Restore(); } + } - public void Translate(SKPoint offset) + public bool HitTest(SKPoint point) + { + return Children.Any(child => child.HitTest(point)); + } + + public IDrawableElement Clone() + { + var newGroup = new DrawableGroup + { + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + foreach (var child in Children) { - var matrix = SKMatrix.CreateTranslation(offset.X, offset.Y); - Transform(matrix); + newGroup.Children.Add(child.Clone()); } + return newGroup; + } + + public void Translate(SKPoint offset) + { + var matrix = SKMatrix.CreateTranslation(offset.X, offset.Y); + Transform(matrix); + } - public void Transform(SKMatrix matrix) + public void Transform(SKMatrix matrix) + { + // Apply the transformation to all children + foreach (var child in Children) { - // Apply the transformation to all children - foreach (var child in Children) - { - child.Transform(matrix); - } + child.Transform(matrix); } + } - public SKPath GetPath() + public SKPath GetPath() + { + var path = new SKPath(); + foreach (var child in Children) { - var path = new SKPath(); - foreach (var child in Children) + using var childPath = child.GetPath(); + if (child is DrawablePath dp && dp.BlendMode == SKBlendMode.Clear) { - using var childPath = child.GetPath(); - if (child is DrawablePath dp && dp.BlendMode == SKBlendMode.Clear) + var result = new SKPath(); + if (path.Op(childPath, SKPathOp.Difference, result)) { - var result = new SKPath(); - if (path.Op(childPath, SKPathOp.Difference, result)) - { - path.Dispose(); - path = result; - } + path.Dispose(); + path = result; } - else + } + else + { + var result = new SKPath(); + if (path.Op(childPath, SKPathOp.Union, result)) { - var result = new SKPath(); - if (path.Op(childPath, SKPathOp.Union, result)) - { - path.Dispose(); - path = result; - } + path.Dispose(); + path = result; } } - return path; } + return path; + } - public SKPath GetGeometryPath() + public SKPath GetGeometryPath() + { + var path = new SKPath(); + foreach (var child in Children) { - var path = new SKPath(); - foreach (var child in Children) - { - using var childPath = child.GetGeometryPath(); - // Union all child geometry paths - var result = new SKPath(); - if (path.Op(childPath, SKPathOp.Union, result)) - { - path.Dispose(); - path = result; - } - } - return path; + using var childPath = child.GetGeometryPath(); + // Union all child geometry paths + var result = new SKPath(); + if (path.Op(childPath, SKPathOp.Union, result)) + { + path.Dispose(); + path = result; + } } + return path; } } \ No newline at end of file diff --git a/Logic/Models/DrawableImage.cs b/Logic/Models/DrawableImage.cs index 44c8c5e..7b6ff4c 100644 --- a/Logic/Models/DrawableImage.cs +++ b/Logic/Models/DrawableImage.cs @@ -23,167 +23,166 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +public class DrawableImage(SKBitmap bitmap) : IDrawableElement { - public class DrawableImage(SKBitmap bitmap) : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public string? SourcePath { get; set; } + public SKBitmap Bitmap { get; set; } = bitmap; + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + + // Images don't typically use FillColor, but we satisfy the interface. + // Could be used for tinting in the future. + public SKColor? FillColor { get; set; } + + // Stroke could be a border around the image + public SKColor StrokeColor { get; set; } = SKColors.Transparent; + public float StrokeWidth { get; set; } = 0; + + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); + + public void Draw(SKCanvas canvas) + { + if (!IsVisible || Bitmap == null) return; + + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); + + var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); + + using var paint = new SKPaint + { + IsAntialias = true, + Color = SKColors.White.WithAlpha(Opacity) // Alpha affects the bitmap draw + }; + + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = 4 / matrix.ScaleX, // Adjust for scale + IsAntialias = true + }; + // Draw slightly outside + var highlightRect = bounds; + highlightRect.Inflate(2, 2); + canvas.DrawRect(highlightRect, highlightPaint); + } + + // Draw Glow + if (IsGlowEnabled && GlowRadius > 0) + { + using var glowPaint = new SKPaint + { + Style = SKPaintStyle.StrokeAndFill, + Color = GlowColor.WithAlpha(Opacity), + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius), + IsAntialias = true + }; + // We draw the rect as the glow source + canvas.DrawRect(bounds, glowPaint); + } + + // Draw the Bitmap + using (var image = SKImage.FromBitmap(Bitmap)) + { + canvas.DrawImage(image, bounds, new SKSamplingOptions(SKFilterMode.Linear), paint); + } + + // Draw Border if set + if (StrokeWidth > 0 && StrokeColor.Alpha > 0) + { + using var borderPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawRect(bounds, borderPaint); + } + + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (Bitmap == null) return false; + + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); + + return bounds.Contains(localPoint); + } + + public IDrawableElement Clone() + { + // Shallow copy of bitmap is usually sufficient unless we edit pixels. + // If we needed deep copy: Bitmap.Copy() + return new DrawableImage(Bitmap) + { + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, // Clones usually start unselected + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } + + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + // Return bounding path + var path = new SKPath(); + if (Bitmap != null) + { + path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); + } + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + if (Bitmap != null) { - public Guid Id { get; } = Guid.NewGuid(); - public string? SourcePath { get; set; } - public SKBitmap Bitmap { get; set; } = bitmap; - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - - // Images don't typically use FillColor, but we satisfy the interface. - // Could be used for tinting in the future. - public SKColor? FillColor { get; set; } - - // Stroke could be a border around the image - public SKColor StrokeColor { get; set; } = SKColors.Transparent; - public float StrokeWidth { get; set; } = 0; - - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); - - public void Draw(SKCanvas canvas) - { - if (!IsVisible || Bitmap == null) return; - - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); - - var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); - - using var paint = new SKPaint - { - IsAntialias = true, - Color = SKColors.White.WithAlpha(Opacity) // Alpha affects the bitmap draw - }; - - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = 4 / matrix.ScaleX, // Adjust for scale - IsAntialias = true - }; - // Draw slightly outside - var highlightRect = bounds; - highlightRect.Inflate(2, 2); - canvas.DrawRect(highlightRect, highlightPaint); - } - - // Draw Glow - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowPaint = new SKPaint - { - Style = SKPaintStyle.StrokeAndFill, - Color = GlowColor.WithAlpha(Opacity), - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius), - IsAntialias = true - }; - // We draw the rect as the glow source - canvas.DrawRect(bounds, glowPaint); - } - - // Draw the Bitmap - using (var image = SKImage.FromBitmap(Bitmap)) - { - canvas.DrawImage(image, bounds, new SKSamplingOptions(SKFilterMode.Linear), paint); - } - - // Draw Border if set - if (StrokeWidth > 0 && StrokeColor.Alpha > 0) - { - using var borderPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true - }; - canvas.DrawRect(bounds, borderPaint); - } - - canvas.Restore(); - } - - public bool HitTest(SKPoint point) - { - if (Bitmap == null) return false; - - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - var bounds = new SKRect(0, 0, Bitmap.Width, Bitmap.Height); - - return bounds.Contains(localPoint); - } - - public IDrawableElement Clone() - { - // Shallow copy of bitmap is usually sufficient unless we edit pixels. - // If we needed deep copy: Bitmap.Copy() - return new DrawableImage(Bitmap) - { - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, // Clones usually start unselected - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius - }; - } - - public void Translate(SKPoint offset) - { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } - - public void Transform(SKMatrix matrix) - { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); - } - - public SKPath GetPath() - { - // Return bounding path - var path = new SKPath(); - if (Bitmap != null) - { - path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); - } - path.Transform(TransformMatrix); - return path; - } - - public SKPath GetGeometryPath() - { - var path = new SKPath(); - if (Bitmap != null) - { - path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); - } - path.Transform(TransformMatrix); - return path; - } + path.AddRect(new SKRect(0, 0, Bitmap.Width, Bitmap.Height)); } + path.Transform(TransformMatrix); + return path; + } } diff --git a/Logic/Models/DrawableLine.cs b/Logic/Models/DrawableLine.cs index 6290c6b..0a369e9 100644 --- a/Logic/Models/DrawableLine.cs +++ b/Logic/Models/DrawableLine.cs @@ -23,174 +23,173 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a line shape on the canvas. +/// +public class DrawableLine : IDrawableElement { - /// - /// Represents a line shape on the canvas. - /// - public class DrawableLine : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public SKPoint StartPoint { get; set; } + public SKPoint EndPoint { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } // Not used for line + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds { - public Guid Id { get; } = Guid.NewGuid(); - public SKPoint StartPoint { get; set; } - public SKPoint EndPoint { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } // Not used for line - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds + get { - get - { - var localBounds = new SKRect( - Math.Min(StartPoint.X, EndPoint.X), - Math.Min(StartPoint.Y, EndPoint.Y), - Math.Max(StartPoint.X, EndPoint.X), - Math.Max(StartPoint.Y, EndPoint.Y) - ); - return TransformMatrix.MapRect(localBounds); - } + var localBounds = new SKRect( + Math.Min(StartPoint.X, EndPoint.X), + Math.Min(StartPoint.Y, EndPoint.Y), + Math.Max(StartPoint.X, EndPoint.X), + Math.Max(StartPoint.Y, EndPoint.Y) + ); + return TransformMatrix.MapRect(localBounds); } + } - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; + public void Draw(SKCanvas canvas) + { + if (!IsVisible) return; - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawLine(StartPoint, EndPoint, highlightPaint); - } - - // Draw glow if enabled - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawLine(StartPoint, EndPoint, glowPaint); - } - - using var paint = new SKPaint + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint { Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, IsAntialias = true }; - canvas.DrawLine(StartPoint, EndPoint, paint); - - canvas.Restore(); + canvas.DrawLine(StartPoint, EndPoint, highlightPaint); } - public bool HitTest(SKPoint point) + // Draw glow if enabled + if (IsGlowEnabled && GlowRadius > 0) { - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - - // Use path-based hit testing for accuracy in local space - using var path = new SKPath(); - path.MoveTo(StartPoint); - path.LineTo(EndPoint); - - using var paint = new SKPaint + using var glowPaint = new SKPaint { Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 10 // Add tolerance - }; - using var strokedPath = new SKPath(); - paint.GetFillPath(path, strokedPath); - return strokedPath.Contains(localPoint.X, localPoint.Y); - } - - public IDrawableElement Clone() - { - return new DrawableLine - { - StartPoint = StartPoint, - EndPoint = EndPoint, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - StrokeColor = StrokeColor, + Color = GlowColor.WithAlpha(Opacity), StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) }; + canvas.DrawLine(StartPoint, EndPoint, glowPaint); } - public void Translate(SKPoint offset) + using var paint = new SKPaint { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawLine(StartPoint, EndPoint, paint); + + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; - public void Transform(SKMatrix matrix) + var localPoint = inverseMatrix.MapPoint(point); + + // Use path-based hit testing for accuracy in local space + using var path = new SKPath(); + path.MoveTo(StartPoint); + path.LineTo(EndPoint); + + using var paint = new SKPaint { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); - } + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 10 // Add tolerance + }; + using var strokedPath = new SKPath(); + paint.GetFillPath(path, strokedPath); + return strokedPath.Contains(localPoint.X, localPoint.Y); + } - public SKPath GetPath() + public IDrawableElement Clone() + { + return new DrawableLine { - var path = new SKPath(); - path.MoveTo(StartPoint); - path.LineTo(EndPoint); + StartPoint = StartPoint, + EndPoint = EndPoint, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } - // Lines are always stroked (no fill) - if (StrokeWidth > 0) - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth, - StrokeCap = SKStrokeCap.Round - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - path.Dispose(); - path = strokePath; - } - - path.Transform(TransformMatrix); - return path; - } + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + var path = new SKPath(); + path.MoveTo(StartPoint); + path.LineTo(EndPoint); - public SKPath GetGeometryPath() + // Lines are always stroked (no fill) + if (StrokeWidth > 0) { - var path = new SKPath(); - path.MoveTo(StartPoint); - path.LineTo(EndPoint); - path.Transform(TransformMatrix); - return path; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth, + StrokeCap = SKStrokeCap.Round + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); + path.Dispose(); + path = strokePath; } + + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + path.MoveTo(StartPoint); + path.LineTo(EndPoint); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawablePath.cs b/Logic/Models/DrawablePath.cs index ff13af1..056c037 100644 --- a/Logic/Models/DrawablePath.cs +++ b/Logic/Models/DrawablePath.cs @@ -23,215 +23,214 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a freehand drawn path on the canvas. +/// +public class DrawablePath : IDrawableElement { - /// - /// Represents a freehand drawn path on the canvas. - /// - public class DrawablePath : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public required SKPath Path { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public SKBlendMode BlendMode { get; set; } = SKBlendMode.SrcOver; + public bool IsFilled { get; set; } + public SKShader? FillShader { get; set; } + + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(Path?.TightBounds ?? SKRect.Empty); + + public void Draw(SKCanvas canvas) { - public Guid Id { get; } = Guid.NewGuid(); - public required SKPath Path { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public SKBlendMode BlendMode { get; set; } = SKBlendMode.SrcOver; - public bool IsFilled { get; set; } - public SKShader? FillShader { get; set; } - - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(Path?.TightBounds ?? SKRect.Empty); - - public void Draw(SKCanvas canvas) + if (!IsVisible || Path == null) return; + + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); + + if (IsGlowEnabled && GlowRadius > 0) { - if (!IsVisible || Path == null) return; + using var glowPaint = new SKPaint + { + Style = IsFilled ? SKPaintStyle.Fill : SKPaintStyle.Stroke, + Color = GlowColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) + }; + canvas.DrawPath(Path, glowPaint); + } - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, + IsAntialias = true + }; + canvas.DrawPath(Path, highlightPaint); + } - if (IsGlowEnabled && GlowRadius > 0) + // Draw Fill + if (IsFilled) + { + using var fillPaint = new SKPaint { - using var glowPaint = new SKPaint - { - Style = IsFilled ? SKPaintStyle.Fill : SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawPath(Path, glowPaint); - } + Style = SKPaintStyle.Fill, + IsAntialias = true, + BlendMode = BlendMode + }; - // Draw selection highlight - if (IsSelected) + if (FillShader != null) { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawPath(Path, highlightPaint); + fillPaint.Shader = FillShader; + // Modulate with opacity/color if needed, but usually white for image shaders + fillPaint.Color = SKColors.White.WithAlpha(Opacity); } - - // Draw Fill - if (IsFilled) + else if (FillColor.HasValue) { - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true, - BlendMode = BlendMode - }; - - if (FillShader != null) - { - fillPaint.Shader = FillShader; - // Modulate with opacity/color if needed, but usually white for image shaders - fillPaint.Color = SKColors.White.WithAlpha(Opacity); - } - else if (FillColor.HasValue) - { - fillPaint.Color = FillColor.Value.WithAlpha(Opacity); - } - else - { - // Fallback: use StrokeColor as fill if no FillColor (matching legacy behavior) - fillPaint.Color = StrokeColor.WithAlpha(Opacity); - } - canvas.DrawPath(Path, fillPaint); + fillPaint.Color = FillColor.Value.WithAlpha(Opacity); } - - // Draw Stroke - if (StrokeWidth > 0) + else { - // Only draw stroke if it's an outline (not filled) OR if it has an explicit fill color (so we preserve border) - // If it is filled but has NO fill color, it is a "solid blob" using StrokeColor, so we skip stroking to avoid double-draw/expansion - // BUT if we have a FillShader, we definitely want the stroke if it exists. - bool shouldStroke = !IsFilled || (IsFilled && (FillColor.HasValue || FillShader != null)); - - if (shouldStroke) - { - using var strokePaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, - IsAntialias = true, - BlendMode = BlendMode, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round - }; - canvas.DrawPath(Path, strokePaint); - } + // Fallback: use StrokeColor as fill if no FillColor (matching legacy behavior) + fillPaint.Color = StrokeColor.WithAlpha(Opacity); } - - canvas.Restore(); + canvas.DrawPath(Path, fillPaint); } - public bool HitTest(SKPoint point) + // Draw Stroke + if (StrokeWidth > 0) { - if (Path == null) return false; + // Only draw stroke if it's an outline (not filled) OR if it has an explicit fill color (so we preserve border) + // If it is filled but has NO fill color, it is a "solid blob" using StrokeColor, so we skip stroking to avoid double-draw/expansion + // BUT if we have a FillShader, we definitely want the stroke if it exists. + bool shouldStroke = !IsFilled || (IsFilled && (FillColor.HasValue || FillShader != null)); - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; - - var localPoint = inverseMatrix.MapPoint(point); - - // Check if point is within bounds first (faster) - if (!Path.TightBounds.Contains(localPoint)) return false; - - // Check if path contains point with tolerance - if (IsFilled) - { - return Path.Contains(localPoint.X, localPoint.Y); - } - else + if (shouldStroke) { - using var paint = new SKPaint + using var strokePaint = new SKPaint { Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 5 // Add tolerance + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true, + BlendMode = BlendMode, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round }; - using var strokedPath = new SKPath(); - paint.GetFillPath(Path, strokedPath); - return strokedPath.Contains(localPoint.X, localPoint.Y); + canvas.DrawPath(Path, strokePaint); } } - public IDrawableElement Clone() - { - return new DrawablePath - { - Path = new SKPath(Path), - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - BlendMode = BlendMode, - IsFilled = IsFilled, - FillShader = FillShader // Share shader reference - }; - } + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (Path == null) return false; - public void Translate(SKPoint offset) + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + + // Check if point is within bounds first (faster) + if (!Path.TightBounds.Contains(localPoint)) return false; + + // Check if path contains point with tolerance + if (IsFilled) { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + return Path.Contains(localPoint.X, localPoint.Y); } - - public void Transform(SKMatrix matrix) + else { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 5 // Add tolerance + }; + using var strokedPath = new SKPath(); + paint.GetFillPath(Path, strokedPath); + return strokedPath.Contains(localPoint.X, localPoint.Y); } + } - public SKPath GetPath() + public IDrawableElement Clone() + { + return new DrawablePath { - var path = new SKPath(Path); + Path = new SKPath(Path), + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + BlendMode = BlendMode, + IsFilled = IsFilled, + FillShader = FillShader // Share shader reference + }; + } - if (!IsFilled && StrokeWidth > 0) - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - path.Dispose(); - path = strokePath; - } - // If IsFilled is true, we assume the path itself is the shape. - // If it has a stroke AND fill, we should technically union them, - // but for freehand paths, usually it's either stroke or fill. - // If we support both later, we can add the union logic here. + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } - path.Transform(TransformMatrix); - return path; - } + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } - public SKPath GetGeometryPath() + public SKPath GetPath() + { + var path = new SKPath(Path); + + if (!IsFilled && StrokeWidth > 0) { - var path = new SKPath(Path); - path.Transform(TransformMatrix); - return path; + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); + path.Dispose(); + path = strokePath; } + // If IsFilled is true, we assume the path itself is the shape. + // If it has a stroke AND fill, we should technically union them, + // but for freehand paths, usually it's either stroke or fill. + // If we support both later, we can add the union logic here. + + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(Path); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawableRectangle.cs b/Logic/Models/DrawableRectangle.cs index e7144bf..73556f6 100644 --- a/Logic/Models/DrawableRectangle.cs +++ b/Logic/Models/DrawableRectangle.cs @@ -23,193 +23,192 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a rectangle shape on the canvas. +/// +public class DrawableRectangle : IDrawableElement { - /// - /// Represents a rectangle shape on the canvas. - /// - public class DrawableRectangle : IDrawableElement + public Guid Id { get; } = Guid.NewGuid(); + public SKRect Rectangle { get; set; } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); + + public bool IsVisible { get; set; } = true; + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + public byte Opacity { get; set; } = 255; + public SKColor? FillColor { get; set; } + public SKColor StrokeColor { get; set; } + public float StrokeWidth { get; set; } + public bool IsGlowEnabled { get; set; } = false; + public SKColor GlowColor { get; set; } = SKColors.Transparent; + public float GlowRadius { get; set; } = 0f; + + public SKRect Bounds => TransformMatrix.MapRect(Rectangle); + + public void Draw(SKCanvas canvas) { - public Guid Id { get; } = Guid.NewGuid(); - public SKRect Rectangle { get; set; } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - public bool IsVisible { get; set; } = true; - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - public byte Opacity { get; set; } = 255; - public SKColor? FillColor { get; set; } - public SKColor StrokeColor { get; set; } - public float StrokeWidth { get; set; } - public bool IsGlowEnabled { get; set; } = false; - public SKColor GlowColor { get; set; } = SKColors.Transparent; - public float GlowRadius { get; set; } = 0f; - - public SKRect Bounds => TransformMatrix.MapRect(Rectangle); - - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; + if (!IsVisible) return; - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); - // Draw selection highlight - if (IsSelected) - { - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = StrokeWidth + 4, - IsAntialias = true - }; - canvas.DrawRect(Rectangle, highlightPaint); - } - - // Draw glow if enabled - if (IsGlowEnabled && GlowRadius > 0) + // Draw selection highlight + if (IsSelected) + { + using var highlightPaint = new SKPaint { - using var glowPaint = new SKPaint - { - Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, - Color = GlowColor.WithAlpha(Opacity), - StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, - IsAntialias = true, - MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) - }; - canvas.DrawRect(Rectangle, glowPaint); - } + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = StrokeWidth + 4, + IsAntialias = true + }; + canvas.DrawRect(Rectangle, highlightPaint); + } - // Draw fill if specified - if (FillColor.HasValue) + // Draw glow if enabled + if (IsGlowEnabled && GlowRadius > 0) + { + using var glowPaint = new SKPaint { - using var fillPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = FillColor.Value.WithAlpha(Opacity), - IsAntialias = true - }; - canvas.DrawRect(Rectangle, fillPaint); - } + Style = FillColor.HasValue ? SKPaintStyle.Fill : SKPaintStyle.Stroke, + Color = GlowColor.WithAlpha(Opacity), + StrokeWidth = FillColor.HasValue ? 0 : StrokeWidth, + IsAntialias = true, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, GlowRadius) + }; + canvas.DrawRect(Rectangle, glowPaint); + } - // Draw stroke - using var strokePaint = new SKPaint + // Draw fill if specified + if (FillColor.HasValue) + { + using var fillPaint = new SKPaint { - Style = SKPaintStyle.Stroke, - Color = StrokeColor.WithAlpha(Opacity), - StrokeWidth = StrokeWidth, + Style = SKPaintStyle.Fill, + Color = FillColor.Value.WithAlpha(Opacity), IsAntialias = true }; - canvas.DrawRect(Rectangle, strokePaint); - - canvas.Restore(); + canvas.DrawRect(Rectangle, fillPaint); } - public bool HitTest(SKPoint point) + // Draw stroke + using var strokePaint = new SKPaint { - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; + Style = SKPaintStyle.Stroke, + Color = StrokeColor.WithAlpha(Opacity), + StrokeWidth = StrokeWidth, + IsAntialias = true + }; + canvas.DrawRect(Rectangle, strokePaint); + + canvas.Restore(); + } - var localPoint = inverseMatrix.MapPoint(point); + public bool HitTest(SKPoint point) + { + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; - using var path = new SKPath(); - path.AddRect(Rectangle); + var localPoint = inverseMatrix.MapPoint(point); - // Check if filled and point is inside the fill path, ONLY if fill is not fully transparent (Alpha > 0) - if (FillColor.HasValue && FillColor.Value.Alpha > 0 && path.Contains(localPoint.X, localPoint.Y)) - { - return true; - } + using var path = new SKPath(); + path.AddRect(Rectangle); - // If fill was transparent or not hit, check if point is near the visible stroke (Alpha > 0) - if (StrokeWidth > 0 && StrokeColor.Alpha > 0) // Only hit visible strokes - { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth + 3 // Reduced tolerance for hit testing - }; - using var strokedPath = new SKPath(); - paint.GetFillPath(path, strokedPath); - return strokedPath.Contains(localPoint.X, localPoint.Y); - } - return false; // No visible fill or stroke hit + // Check if filled and point is inside the fill path, ONLY if fill is not fully transparent (Alpha > 0) + if (FillColor.HasValue && FillColor.Value.Alpha > 0 && path.Contains(localPoint.X, localPoint.Y)) + { + return true; } - public IDrawableElement Clone() + // If fill was transparent or not hit, check if point is near the visible stroke (Alpha > 0) + if (StrokeWidth > 0 && StrokeColor.Alpha > 0) // Only hit visible strokes { - return new DrawableRectangle + using var paint = new SKPaint { - Rectangle = Rectangle, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth + 3 // Reduced tolerance for hit testing }; + using var strokedPath = new SKPath(); + paint.GetFillPath(path, strokedPath); + return strokedPath.Contains(localPoint.X, localPoint.Y); } + return false; // No visible fill or stroke hit + } - public void Translate(SKPoint offset) + public IDrawableElement Clone() + { + return new DrawableRectangle { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); - } + Rectangle = Rectangle, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius + }; + } - public void Transform(SKMatrix matrix) - { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); - } + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + var path = new SKPath(); + path.AddRect(Rectangle); - public SKPath GetPath() + if (StrokeWidth > 0) { - var path = new SKPath(); - path.AddRect(Rectangle); + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = StrokeWidth, + StrokeJoin = SKStrokeJoin.Miter + }; + var strokePath = new SKPath(); + paint.GetFillPath(path, strokePath); - if (StrokeWidth > 0) + if (FillColor.HasValue) { - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeWidth, - StrokeJoin = SKStrokeJoin.Miter - }; - var strokePath = new SKPath(); - paint.GetFillPath(path, strokePath); - - if (FillColor.HasValue) - { - var combined = new SKPath(); - // Union the fill (original path) and the stroke - // path OP strokePath -> combined - path.Op(strokePath, SKPathOp.Union, combined); - path.Dispose(); - path = combined; - } - else - { - path.Dispose(); - path = strokePath; - } + var combined = new SKPath(); + // Union the fill (original path) and the stroke + // path OP strokePath -> combined + path.Op(strokePath, SKPathOp.Union, combined); + path.Dispose(); + path = combined; + } + else + { + path.Dispose(); + path = strokePath; } - - path.Transform(TransformMatrix); - return path; } - public SKPath GetGeometryPath() - { - var path = new SKPath(); - path.AddRect(Rectangle); - path.Transform(TransformMatrix); - return path; - } + path.Transform(TransformMatrix); + return path; + } + + public SKPath GetGeometryPath() + { + var path = new SKPath(); + path.AddRect(Rectangle); + path.Transform(TransformMatrix); + return path; } } diff --git a/Logic/Models/DrawableStamps.cs b/Logic/Models/DrawableStamps.cs index 86054ea..845924a 100644 --- a/Logic/Models/DrawableStamps.cs +++ b/Logic/Models/DrawableStamps.cs @@ -23,673 +23,672 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a series of stamped shapes (custom brush strokes). +/// +public class DrawableStamps : IDrawableElement { - /// - /// Represents a series of stamped shapes (custom brush strokes). - /// - public class DrawableStamps : IDrawableElement - { - private SKBitmap? cachedBitmap; - private SKPoint cacheOffset; - private bool isCacheDirty = true; + private SKBitmap? cachedBitmap; + private SKPoint cacheOffset; + private bool isCacheDirty = true; - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = Guid.NewGuid(); - private List points = []; - public List Points + private List points = []; + public List Points + { + get => points; + set { - get => points; - set - { - points = value; - InvalidateCache(); - } + points = value; + InvalidateCache(); } + } - private BrushShape shape = BrushShape.Circle(); - public BrushShape Shape + private BrushShape shape = BrushShape.Circle(); + public BrushShape Shape + { + get => shape; + set { - get => shape; - set - { - shape = value; - InvalidateCache(); - } - } - - private float size = 10f; - public float Size - { - get => size; - set - { - if (Math.Abs(size - value) > 0.001f) - { - size = value; - InvalidateCache(); - } - } + shape = value; + InvalidateCache(); } + } - private byte flow = 255; - public byte Flow - { - get => flow; - set - { - if (flow != value) - { - flow = value; - InvalidateCache(); - } - } + private float size = 10f; + public float Size + { + get => size; + set + { + if (Math.Abs(size - value) > 0.001f) + { + size = value; + InvalidateCache(); + } } + } - public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - - private bool isVisible = true; - public bool IsVisible + private byte flow = 255; + public byte Flow + { + get => flow; + set { - get => isVisible; - set => isVisible = value; + if (flow != value) + { + flow = value; + InvalidateCache(); + } } + } - public bool IsSelected { get; set; } - public int ZIndex { get; set; } - - private byte opacity = 255; - public byte Opacity - { - get => opacity; - set - { - if (opacity != value) - { - opacity = value; - InvalidateCache(); - } - } - } + public SKMatrix TransformMatrix { get; set; } = SKMatrix.CreateIdentity(); - public SKColor? FillColor { get; set; } + private bool isVisible = true; + public bool IsVisible + { + get => isVisible; + set => isVisible = value; + } - private SKColor strokeColor = SKColors.Black; - public SKColor StrokeColor + public bool IsSelected { get; set; } + public int ZIndex { get; set; } + + private byte opacity = 255; + public byte Opacity + { + get => opacity; + set { - get => strokeColor; - set - { - if (strokeColor != value) - { - strokeColor = value; - InvalidateCache(); - } - } + if (opacity != value) + { + opacity = value; + InvalidateCache(); + } } + } - public float StrokeWidth { get; set; } // Not used directly, using Size instead + public SKColor? FillColor { get; set; } - private SKBlendMode blendMode = SKBlendMode.SrcOver; - public SKBlendMode BlendMode + private SKColor strokeColor = SKColors.Black; + public SKColor StrokeColor + { + get => strokeColor; + set { - get => blendMode; - set - { - if (blendMode != value) - { - blendMode = value; - InvalidateCache(); - } - } + if (strokeColor != value) + { + strokeColor = value; + InvalidateCache(); + } } + } - public bool IsFilled { get; set; } = true; + public float StrokeWidth { get; set; } // Not used directly, using Size instead - private bool isGlowEnabled = false; - public bool IsGlowEnabled + private SKBlendMode blendMode = SKBlendMode.SrcOver; + public SKBlendMode BlendMode + { + get => blendMode; + set { - get => isGlowEnabled; - set - { - if (isGlowEnabled != value) - { - isGlowEnabled = value; - InvalidateCache(); - } - } + if (blendMode != value) + { + blendMode = value; + InvalidateCache(); + } } + } - private SKColor glowColor = SKColors.Transparent; - public SKColor GlowColor - { - get => glowColor; - set - { - if (glowColor != value) - { - glowColor = value; - InvalidateCache(); - } - } - } + public bool IsFilled { get; set; } = true; - private float glowRadius = 0f; - public float GlowRadius - { - get => glowRadius; - set - { - if (Math.Abs(glowRadius - value) > 0.001f) - { - glowRadius = value; - InvalidateCache(); - } - } + private bool isGlowEnabled = false; + public bool IsGlowEnabled + { + get => isGlowEnabled; + set + { + if (isGlowEnabled != value) + { + isGlowEnabled = value; + InvalidateCache(); + } } + } - private bool isRainbowEnabled; - public bool IsRainbowEnabled - { - get => isRainbowEnabled; - set - { - if (isRainbowEnabled != value) - { - isRainbowEnabled = value; - InvalidateCache(); - } - } + private SKColor glowColor = SKColors.Transparent; + public SKColor GlowColor + { + get => glowColor; + set + { + if (glowColor != value) + { + glowColor = value; + InvalidateCache(); + } } + } - private List rotations = []; - public List Rotations + private float glowRadius = 0f; + public float GlowRadius + { + get => glowRadius; + set { - get => rotations; - set - { - rotations = value; - InvalidateCache(); - } + if (Math.Abs(glowRadius - value) > 0.001f) + { + glowRadius = value; + InvalidateCache(); + } } + } - private float sizeJitter; - public float SizeJitter - { - get => sizeJitter; - set - { - if (Math.Abs(sizeJitter - value) > 0.001f) - { - sizeJitter = value; - InvalidateCache(); - } - } + private bool isRainbowEnabled; + public bool IsRainbowEnabled + { + get => isRainbowEnabled; + set + { + if (isRainbowEnabled != value) + { + isRainbowEnabled = value; + InvalidateCache(); + } } + } - private float angleJitter; - public float AngleJitter - { - get => angleJitter; - set - { - if (Math.Abs(angleJitter - value) > 0.001f) - { - angleJitter = value; - InvalidateCache(); - } - } + private List rotations = []; + public List Rotations + { + get => rotations; + set + { + rotations = value; + InvalidateCache(); } + } - private float hueJitter; - public float HueJitter - { - get => hueJitter; - set - { - if (Math.Abs(hueJitter - value) > 0.001f) - { - hueJitter = value; - InvalidateCache(); - } - } + private float sizeJitter; + public float SizeJitter + { + get => sizeJitter; + set + { + if (Math.Abs(sizeJitter - value) > 0.001f) + { + sizeJitter = value; + InvalidateCache(); + } } + } - private void InvalidateCache() + private float angleJitter; + public float AngleJitter + { + get => angleJitter; + set { - isCacheDirty = true; - cachedBitmap?.Dispose(); - cachedBitmap = null; + if (Math.Abs(angleJitter - value) > 0.001f) + { + angleJitter = value; + InvalidateCache(); + } } + } - private SKRect GetLocalBounds() - { - if (Points == null || !Points.Any()) return SKRect.Empty; - - // Conservative bounds with jitter - float maxScale = 1.0f + SizeJitter; // Assuming SizeJitter is 0-1 relative addition - float halfSize = Size * maxScale; - - float minX = Points.Min(p => p.X); - float minY = Points.Min(p => p.Y); - float maxX = Points.Max(p => p.X); - float maxY = Points.Max(p => p.Y); - - float glowPadding = IsGlowEnabled ? GlowRadius * 3 : 0; - float padding = glowPadding + 5; // Extra safety margin - - return new SKRect( - minX - halfSize - padding, - minY - halfSize - padding, - maxX + halfSize + padding, - maxY + halfSize + padding); + private float hueJitter; + public float HueJitter + { + get => hueJitter; + set + { + if (Math.Abs(hueJitter - value) > 0.001f) + { + hueJitter = value; + InvalidateCache(); + } } + } - public SKRect Bounds => TransformMatrix.MapRect(GetLocalBounds()); + private void InvalidateCache() + { + isCacheDirty = true; + cachedBitmap?.Dispose(); + cachedBitmap = null; + } - private void UpdateCache() - { - if (!isCacheDirty && cachedBitmap != null) return; - if (Points == null || !Points.Any() || Shape?.Path == null) return; + private SKRect GetLocalBounds() + { + if (Points == null || !Points.Any()) return SKRect.Empty; + + // Conservative bounds with jitter + float maxScale = 1.0f + SizeJitter; // Assuming SizeJitter is 0-1 relative addition + float halfSize = Size * maxScale; + + float minX = Points.Min(p => p.X); + float minY = Points.Min(p => p.Y); + float maxX = Points.Max(p => p.X); + float maxY = Points.Max(p => p.Y); + + float glowPadding = IsGlowEnabled ? GlowRadius * 3 : 0; + float padding = glowPadding + 5; // Extra safety margin + + return new SKRect( + minX - halfSize - padding, + minY - halfSize - padding, + maxX + halfSize + padding, + maxY + halfSize + padding); + } + + public SKRect Bounds => TransformMatrix.MapRect(GetLocalBounds()); + + private void UpdateCache() + { + if (!isCacheDirty && cachedBitmap != null) return; + if (Points == null || !Points.Any() || Shape?.Path == null) return; + + cachedBitmap?.Dispose(); + cachedBitmap = null; + + var bounds = GetLocalBounds(); + var width = (int)Math.Ceiling(bounds.Width); + var height = (int)Math.Ceiling(bounds.Height); + + if (width <= 0 || height <= 0) return; + + cachedBitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(cachedBitmap); + canvas.Clear(SKColors.Transparent); + canvas.Translate(-bounds.Left, -bounds.Top); + + DrawContent(canvas); + + cacheOffset = new SKPoint(bounds.Left, bounds.Top); + isCacheDirty = false; + } + + private void DrawContent(SKCanvas canvas) + { + if (Points == null || !Points.Any() || Shape?.Path == null) return; - cachedBitmap?.Dispose(); - cachedBitmap = null; + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); - var bounds = GetLocalBounds(); - var width = (int)Math.Ceiling(bounds.Width); - var height = (int)Math.Ceiling(bounds.Height); + using var sharedPaint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true, + BlendMode = BlendMode + }; + + // Pre-calculate or deterministically generate variations + // Using a simple random generator seeded with a constant for stability if needed, + // but here we might just use index-based hashing for stateless drawing. - if (width <= 0 || height <= 0) return; + // Glow pass (Optimized with SaveLayer) + if (IsGlowEnabled && GlowRadius > 0) + { + using var glowLayerPaint = new SKPaint + { + ImageFilter = SKImageFilter.CreateBlur(GlowRadius, GlowRadius), + IsAntialias = true + }; - cachedBitmap = new SKBitmap(width, height); - using var canvas = new SKCanvas(cachedBitmap); - canvas.Clear(SKColors.Transparent); - canvas.Translate(-bounds.Left, -bounds.Top); + canvas.SaveLayer(glowLayerPaint); - DrawContent(canvas); + int index = 0; + foreach (var point in Points) + { + DrawSingleStamp(canvas, scaledPath, point, index, true, sharedPaint); + index++; + } - cacheOffset = new SKPoint(bounds.Left, bounds.Top); - isCacheDirty = false; + canvas.Restore(); // Apply blur } - private void DrawContent(SKCanvas canvas) - { - if (Points == null || !Points.Any() || Shape?.Path == null) return; - - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); - - using var sharedPaint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true, - BlendMode = BlendMode - }; - - // Pre-calculate or deterministically generate variations - // Using a simple random generator seeded with a constant for stability if needed, - // but here we might just use index-based hashing for stateless drawing. - - // Glow pass (Optimized with SaveLayer) - if (IsGlowEnabled && GlowRadius > 0) - { - using var glowLayerPaint = new SKPaint - { - ImageFilter = SKImageFilter.CreateBlur(GlowRadius, GlowRadius), - IsAntialias = true - }; - - canvas.SaveLayer(glowLayerPaint); - - int index = 0; - foreach (var point in Points) - { - DrawSingleStamp(canvas, scaledPath, point, index, true, sharedPaint); - index++; - } - - canvas.Restore(); // Apply blur - } - - // Main pass - int i = 0; - foreach (var point in Points) - { - DrawSingleStamp(canvas, scaledPath, point, i, false, sharedPaint); - i++; - } + // Main pass + int i = 0; + foreach (var point in Points) + { + DrawSingleStamp(canvas, scaledPath, point, i, false, sharedPaint); + i++; } + } - private int GetStableSeed(SKPoint p) + private int GetStableSeed(SKPoint p) + { + unchecked { - unchecked - { - int hash = 17; - hash = hash * 23 + p.X.GetHashCode(); - hash = hash * 23 + p.Y.GetHashCode(); - return hash; - } + int hash = 17; + hash = hash * 23 + p.X.GetHashCode(); + hash = hash * 23 + p.Y.GetHashCode(); + return hash; } + } - private void DrawSingleStamp(SKCanvas canvas, SKPath basePath, SKPoint point, int index, bool isGlowPass, SKPaint paint) - { - // Deterministic Random based on point location - var random = new Random(GetStableSeed(point)); - - // Size Jitter - float scaleFactor = 1.0f; - if (SizeJitter > 0) - { - float jitter = (float)random.NextDouble() * SizeJitter; // 0 to SizeJitter - scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (scaleFactor < 0.1f) scaleFactor = 0.1f; - } - - // Angle Jitter - float rotationDelta = 0f; - if (AngleJitter > 0) - { - rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; // +/- AngleJitter - } - - // Color Jitter / Rainbow - SKColor color = isGlowPass ? GlowColor : StrokeColor; - - if (IsRainbowEnabled) - { - // Rainbow cycles through Hue based on index - float hue = index * 10 % 360; // 10 degrees per stamp - color = SKColor.FromHsl(hue, 100, 50); - } - else if (HueJitter > 0 && !isGlowPass) - { - // Apply Hue Jitter - color.ToHsl(out float h, out float s, out float l); - float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; // +/- HueJitter (0-1 -> 0-360) - h = (h + jitter) % 360f; - if (h < 0) h += 360f; - color = SKColor.FromHsl(h, s, l); - } - - // Apply opacity and flow - paint.Color = color.WithAlpha((byte)(Flow * (Opacity / 255f))); - - canvas.Save(); - canvas.Translate(point.X, point.Y); - - // Apply Base Rotation (from path direction) + Jitter - float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; - float finalRotation = baseRotation + rotationDelta; - - if (Math.Abs(finalRotation) > 0.001f) - { - canvas.RotateDegrees(finalRotation); - } - - if (scaleFactor != 1.0f) - { - canvas.Scale(scaleFactor); - } - - canvas.DrawPath(basePath, paint); - canvas.Restore(); + private void DrawSingleStamp(SKCanvas canvas, SKPath basePath, SKPoint point, int index, bool isGlowPass, SKPaint paint) + { + // Deterministic Random based on point location + var random = new Random(GetStableSeed(point)); + + // Size Jitter + float scaleFactor = 1.0f; + if (SizeJitter > 0) + { + float jitter = (float)random.NextDouble() * SizeJitter; // 0 to SizeJitter + scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (scaleFactor < 0.1f) scaleFactor = 0.1f; } - public void Draw(SKCanvas canvas) - { - if (!IsVisible) return; - - canvas.Save(); - var matrix = TransformMatrix; - canvas.Concat(in matrix); - - if (IsSelected) - { - // Draw selection highlight based on simple bounds (ignoring glow for the box) - float halfSize = Size; - float minX = Points.Min(p => p.X); - float minY = Points.Min(p => p.Y); - float maxX = Points.Max(p => p.X); - float maxY = Points.Max(p => p.Y); - var localBounds = new SKRect(minX - halfSize, minY - halfSize, maxX + halfSize, maxY + halfSize); - - using var highlightPaint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue.WithAlpha(128), - StrokeWidth = 2, - IsAntialias = true - }; - canvas.DrawRect(localBounds, highlightPaint); - } - - // Always try to use cache for content - UpdateCache(); - if (cachedBitmap != null) - { - canvas.DrawBitmap(cachedBitmap, cacheOffset); - } - else - { - // Fallback - DrawContent(canvas); - } - - canvas.Restore(); + // Angle Jitter + float rotationDelta = 0f; + if (AngleJitter > 0) + { + rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; // +/- AngleJitter } - public bool HitTest(SKPoint point) + // Color Jitter / Rainbow + SKColor color = isGlowPass ? GlowColor : StrokeColor; + + if (IsRainbowEnabled) + { + // Rainbow cycles through Hue based on index + float hue = index * 10 % 360; // 10 degrees per stamp + color = SKColor.FromHsl(hue, 100, 50); + } + else if (HueJitter > 0 && !isGlowPass) { - if (Points == null || !Points.Any() || Shape?.Path == null) return false; + // Apply Hue Jitter + color.ToHsl(out float h, out float s, out float l); + float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; // +/- HueJitter (0-1 -> 0-360) + h = (h + jitter) % 360f; + if (h < 0) h += 360f; + color = SKColor.FromHsl(h, s, l); + } - if (!TransformMatrix.TryInvert(out var inverseMatrix)) - return false; + // Apply opacity and flow + paint.Color = color.WithAlpha((byte)(Flow * (Opacity / 255f))); - var localPoint = inverseMatrix.MapPoint(point); + canvas.Save(); + canvas.Translate(point.X, point.Y); - // We need to iterate through each stamp and perform a hit test on its individual path - // replicating the jitter and transformations - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var initialScaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(initialScaleMatrix); // Apply base scale once + // Apply Base Rotation (from path direction) + Jitter + float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; + float finalRotation = baseRotation + rotationDelta; - for (int index = 0; index < Points.Count; index++) - { - var stampPoint = Points[index]; - // Deterministic Random based on point location - var random = new Random(GetStableSeed(stampPoint)); - - // Calculate current stamp's transformations - float currentScaleFactor = 1.0f; - if (SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption - currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; - } - - float currentRotationDelta = 0f; - if (AngleJitter > 0) - { - currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; - } - float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; - float finalRotation = baseRotation + currentRotationDelta; - - // Create the individual stamp's path with its transformations - using var stampPath = new SKPath(scaledPath); // Start with the pre-scaled shape path - - // Apply individual stamp's jittered scale and rotation - SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); - stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); - stampPath.Transform(stampTransform); - - // Translate to stamp's center point - stampPath.Transform(SKMatrix.CreateTranslation(stampPoint.X, stampPoint.Y)); - - // Check for visible fill hit (Alpha > 0) - SKColor effectiveFillColor = StrokeColor; // Stamps are usually filled with stroke color for simplicity - - // If stamp opacity * element opacity makes it transparent, it shouldn't hit. - // For stamps, Flow * Opacity is the effective alpha applied to color. - byte effectiveAlpha = (byte)(Flow * (Opacity / 255f)); - - if (effectiveAlpha > 0 && stampPath.Contains(localPoint.X, localPoint.Y)) - { - return true; - } - } - - return false; // No stamp hit + if (Math.Abs(finalRotation) > 0.001f) + { + canvas.RotateDegrees(finalRotation); + } + + if (scaleFactor != 1.0f) + { + canvas.Scale(scaleFactor); } - public IDrawableElement Clone() + canvas.DrawPath(basePath, paint); + canvas.Restore(); + } + + public void Draw(SKCanvas canvas) + { + if (!IsVisible) return; + + canvas.Save(); + var matrix = TransformMatrix; + canvas.Concat(in matrix); + + if (IsSelected) { - return new DrawableStamps + // Draw selection highlight based on simple bounds (ignoring glow for the box) + float halfSize = Size; + float minX = Points.Min(p => p.X); + float minY = Points.Min(p => p.Y); + float maxX = Points.Max(p => p.X); + float maxY = Points.Max(p => p.Y); + var localBounds = new SKRect(minX - halfSize, minY - halfSize, maxX + halfSize, maxY + halfSize); + + using var highlightPaint = new SKPaint { - Points = new List(Points), - Shape = Shape, // Reference copy is fine for shape - Size = Size, - Flow = Flow, - TransformMatrix = TransformMatrix, - IsVisible = IsVisible, - IsSelected = false, - ZIndex = ZIndex, - Opacity = Opacity, - FillColor = FillColor, - StrokeColor = StrokeColor, - StrokeWidth = StrokeWidth, - BlendMode = BlendMode, - IsFilled = IsFilled, - IsGlowEnabled = IsGlowEnabled, - GlowColor = GlowColor, - GlowRadius = GlowRadius, - IsRainbowEnabled = IsRainbowEnabled, - Rotations = new List(Rotations), - SizeJitter = SizeJitter, - AngleJitter = AngleJitter, - HueJitter = HueJitter + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue.WithAlpha(128), + StrokeWidth = 2, + IsAntialias = true }; + canvas.DrawRect(localBounds, highlightPaint); } - public void Translate(SKPoint offset) + // Always try to use cache for content + UpdateCache(); + if (cachedBitmap != null) { - var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); - TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + canvas.DrawBitmap(cachedBitmap, cacheOffset); } - - public void Transform(SKMatrix matrix) + else { - TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + // Fallback + DrawContent(canvas); } - public SKPath GetPath() + canvas.Restore(); + } + + public bool HitTest(SKPoint point) + { + if (Points == null || !Points.Any() || Shape?.Path == null) return false; + + if (!TransformMatrix.TryInvert(out var inverseMatrix)) + return false; + + var localPoint = inverseMatrix.MapPoint(point); + + // We need to iterate through each stamp and perform a hit test on its individual path + // replicating the jitter and transformations + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var initialScaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(initialScaleMatrix); // Apply base scale once + + for (int index = 0; index < Points.Count; index++) { - // Returning a combined path is expensive but necessary if we want to convert to standard path - var combinedPath = new SKPath(); - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); + var stampPoint = Points[index]; + // Deterministic Random based on point location + var random = new Random(GetStableSeed(stampPoint)); + + // Calculate current stamp's transformations + float currentScaleFactor = 1.0f; + if (SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption + currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; + } + + float currentRotationDelta = 0f; + if (AngleJitter > 0) + { + currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; + } + float baseRotation = (Rotations != null && index < Rotations.Count) ? Rotations[index] : 0f; + float finalRotation = baseRotation + currentRotationDelta; + + // Create the individual stamp's path with its transformations + using var stampPath = new SKPath(scaledPath); // Start with the pre-scaled shape path + + // Apply individual stamp's jittered scale and rotation + SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); + stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); + stampPath.Transform(stampTransform); - for (int i = 0; i < Points.Count; i++) + // Translate to stamp's center point + stampPath.Transform(SKMatrix.CreateTranslation(stampPoint.X, stampPoint.Y)); + + // Check for visible fill hit (Alpha > 0) + SKColor effectiveFillColor = StrokeColor; // Stamps are usually filled with stroke color for simplicity + + // If stamp opacity * element opacity makes it transparent, it shouldn't hit. + // For stamps, Flow * Opacity is the effective alpha applied to color. + byte effectiveAlpha = (byte)(Flow * (Opacity / 255f)); + + if (effectiveAlpha > 0 && stampPath.Contains(localPoint.X, localPoint.Y)) { - var point = Points[i]; - var random = new Random(GetStableSeed(point)); - - float currentScaleFactor = 1.0f; - if (SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption - currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; - } - - float currentRotationDelta = 0f; - if (AngleJitter > 0) - { - currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; - } - float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; - float finalRotation = baseRotation + currentRotationDelta; - - var p = new SKPath(scaledPath); - - SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); - stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); - p.Transform(stampTransform); - - p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); - combinedPath.AddPath(p); + return true; } - combinedPath.Transform(TransformMatrix); - return combinedPath; } - public SKPath GetGeometryPath() + return false; // No stamp hit + } + + public IDrawableElement Clone() + { + return new DrawableStamps { - return GetPath(); // For stamps, the "geometry path" is the combined visual path. + Points = new List(Points), + Shape = Shape, // Reference copy is fine for shape + Size = Size, + Flow = Flow, + TransformMatrix = TransformMatrix, + IsVisible = IsVisible, + IsSelected = false, + ZIndex = ZIndex, + Opacity = Opacity, + FillColor = FillColor, + StrokeColor = StrokeColor, + StrokeWidth = StrokeWidth, + BlendMode = BlendMode, + IsFilled = IsFilled, + IsGlowEnabled = IsGlowEnabled, + GlowColor = GlowColor, + GlowRadius = GlowRadius, + IsRainbowEnabled = IsRainbowEnabled, + Rotations = new List(Rotations), + SizeJitter = SizeJitter, + AngleJitter = AngleJitter, + HueJitter = HueJitter + }; + } + + public void Translate(SKPoint offset) + { + var translation = SKMatrix.CreateTranslation(offset.X, offset.Y); + TransformMatrix = SKMatrix.Concat(translation, TransformMatrix); + } + + public void Transform(SKMatrix matrix) + { + TransformMatrix = SKMatrix.Concat(matrix, TransformMatrix); + } + + public SKPath GetPath() + { + // Returning a combined path is expensive but necessary if we want to convert to standard path + var combinedPath = new SKPath(); + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); + + for (int i = 0; i < Points.Count; i++) + { + var point = Points[i]; + var random = new Random(GetStableSeed(point)); + + float currentScaleFactor = 1.0f; + if (SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * SizeJitter; // Match DrawSingleStamp consumption + currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; + } + + float currentRotationDelta = 0f; + if (AngleJitter > 0) + { + currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; + } + float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; + float finalRotation = baseRotation + currentRotationDelta; + + var p = new SKPath(scaledPath); + + SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); + stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); + p.Transform(stampTransform); + + p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); + combinedPath.AddPath(p); } + combinedPath.Transform(TransformMatrix); + return combinedPath; + } + + public SKPath GetGeometryPath() + { + return GetPath(); // For stamps, the "geometry path" is the combined visual path. + } + + public IEnumerable<(SKPath Path, SKColor Color)> GetDetailedPaths() + { + float baseScale = Size / 20f; + using var scaledPath = new SKPath(Shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); + + for (int i = 0; i < Points.Count; i++) + { + var point = Points[i]; + var random = new Random(GetStableSeed(point)); + + // Path Calculation + float currentScaleFactor = 1.0f; + if (SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * SizeJitter; + currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; + if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; + } + + float currentRotationDelta = 0f; + if (AngleJitter > 0) + { + currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; + } + float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; + float finalRotation = baseRotation + currentRotationDelta; + + var p = new SKPath(scaledPath); + SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); + stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); + p.Transform(stampTransform); + p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); + + // Apply Global Transform + p.Transform(TransformMatrix); + + // Color Calculation + SKColor color = StrokeColor; + if (IsRainbowEnabled) + { + float hue = i * 10 % 360; + color = SKColor.FromHsl(hue, 100, 50); + } + else if (HueJitter > 0) + { + color.ToHsl(out float h, out float s, out float l); + float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; + h = (h + jitter) % 360f; + if (h < 0) h += 360f; + color = SKColor.FromHsl(h, s, l); + } - public IEnumerable<(SKPath Path, SKColor Color)> GetDetailedPaths() - { - float baseScale = Size / 20f; - using var scaledPath = new SKPath(Shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); - - for (int i = 0; i < Points.Count; i++) - { - var point = Points[i]; - var random = new Random(GetStableSeed(point)); - - // Path Calculation - float currentScaleFactor = 1.0f; - if (SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * SizeJitter; - currentScaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * SizeJitter; - if (currentScaleFactor < 0.1f) currentScaleFactor = 0.1f; - } - - float currentRotationDelta = 0f; - if (AngleJitter > 0) - { - currentRotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * AngleJitter; - } - float baseRotation = (Rotations != null && i < Rotations.Count) ? Rotations[i] : 0f; - float finalRotation = baseRotation + currentRotationDelta; - - var p = new SKPath(scaledPath); - SKMatrix stampTransform = SKMatrix.CreateScale(currentScaleFactor, currentScaleFactor, 0, 0); - stampTransform = stampTransform.PostConcat(SKMatrix.CreateRotationDegrees(finalRotation, 0, 0)); - p.Transform(stampTransform); - p.Transform(SKMatrix.CreateTranslation(point.X, point.Y)); - - // Apply Global Transform - p.Transform(TransformMatrix); - - // Color Calculation - SKColor color = StrokeColor; - if (IsRainbowEnabled) - { - float hue = i * 10 % 360; - color = SKColor.FromHsl(hue, 100, 50); - } - else if (HueJitter > 0) - { - color.ToHsl(out float h, out float s, out float l); - float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * HueJitter * 360f; - h = (h + jitter) % 360f; - if (h < 0) h += 360f; - color = SKColor.FromHsl(h, s, l); - } - - yield return (p, color); - } + yield return (p, color); } } } diff --git a/Logic/Models/IDrawableElement.cs b/Logic/Models/IDrawableElement.cs index af759ca..efd396f 100644 --- a/Logic/Models/IDrawableElement.cs +++ b/Logic/Models/IDrawableElement.cs @@ -23,109 +23,108 @@ using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Base interface for all drawable elements on the canvas. +/// Supports selection, visibility, layering, and manipulation. +/// +public interface IDrawableElement { - /// - /// Base interface for all drawable elements on the canvas. - /// Supports selection, visibility, layering, and manipulation. - /// - public interface IDrawableElement - { - /// - /// Unique identifier for this element. - /// - Guid Id { get; } - - /// - /// Bounding rectangle of the element in world coordinates. - /// - SKRect Bounds { get; } - - /// - /// The transformation matrix applied to the element. - /// - SKMatrix TransformMatrix { get; set; } - - /// - /// Whether the element is visible on the canvas. - /// - bool IsVisible { get; set; } - - /// - /// Whether the element is currently selected. - /// - bool IsSelected { get; set; } - - /// - /// Z-index for layering (higher values drawn on top). - /// - int ZIndex { get; set; } - - /// - /// Opacity of the element (0-255). - /// - byte Opacity { get; set; } - - /// - /// Fill color for the element (null for no fill). - /// - SKColor? FillColor { get; set; } - - /// - /// Stroke/border color for the element. - /// - SKColor StrokeColor { get; set; } - - /// - /// Width of the stroke/border. - /// - float StrokeWidth { get; set; } - bool IsGlowEnabled { get; set; } - SKColor GlowColor { get; set; } - float GlowRadius { get; set; } - - /// - /// Draws the element on the provided canvas. - /// - /// The SKCanvas to draw on. - void Draw(SKCanvas canvas); - - /// - /// Tests if a point hits this element. - /// - /// The point to test in world coordinates. - /// True if the point intersects with the element. - bool HitTest(SKPoint point); - - /// - /// Creates a deep copy of this element. - /// - /// A cloned instance of the element. - IDrawableElement Clone(); - - /// - /// Translates the element by the specified offset. - /// - /// The offset to move by. - void Translate(SKPoint offset); - - /// - /// Transforms the element using the provided matrix. - /// - /// The transformation matrix. - void Transform(SKMatrix matrix); - - /// - /// Gets the geometric path of the element in world coordinates. - /// - /// The SKPath representing the element. - SKPath GetPath(); - - /// - /// Gets the underlying geometry path without stroke expansion. - /// Used for operations that need the base shape. - /// - /// The SKPath representing the base geometry. - SKPath GetGeometryPath(); - } -} + /// + /// Unique identifier for this element. + /// + Guid Id { get; } + + /// + /// Bounding rectangle of the element in world coordinates. + /// + SKRect Bounds { get; } + + /// + /// The transformation matrix applied to the element. + /// + SKMatrix TransformMatrix { get; set; } + + /// + /// Whether the element is visible on the canvas. + /// + bool IsVisible { get; set; } + + /// + /// Whether the element is currently selected. + /// + bool IsSelected { get; set; } + + /// + /// Z-index for layering (higher values drawn on top). + /// + int ZIndex { get; set; } + + /// + /// Opacity of the element (0-255). + /// + byte Opacity { get; set; } + + /// + /// Fill color for the element (null for no fill). + /// + SKColor? FillColor { get; set; } + + /// + /// Stroke/border color for the element. + /// + SKColor StrokeColor { get; set; } + + /// + /// Width of the stroke/border. + /// + float StrokeWidth { get; set; } + bool IsGlowEnabled { get; set; } + SKColor GlowColor { get; set; } + float GlowRadius { get; set; } + + /// + /// Draws the element on the provided canvas. + /// + /// The SKCanvas to draw on. + void Draw(SKCanvas canvas); + + /// + /// Tests if a point hits this element. + /// + /// The point to test in world coordinates. + /// True if the point intersects with the element. + bool HitTest(SKPoint point); + + /// + /// Creates a deep copy of this element. + /// + /// A cloned instance of the element. + IDrawableElement Clone(); + + /// + /// Translates the element by the specified offset. + /// + /// The offset to move by. + void Translate(SKPoint offset); + + /// + /// Transforms the element using the provided matrix. + /// + /// The transformation matrix. + void Transform(SKMatrix matrix); + + /// + /// Gets the geometric path of the element in world coordinates. + /// + /// The SKPath representing the element. + SKPath GetPath(); + + /// + /// Gets the underlying geometry path without stroke expansion. + /// Used for operations that need the base shape. + /// + /// The SKPath representing the base geometry. + SKPath GetGeometryPath(); +} \ No newline at end of file diff --git a/Logic/Models/Layer.cs b/Logic/Models/Layer.cs index 9cd050f..026c206 100644 --- a/Logic/Models/Layer.cs +++ b/Logic/Models/Layer.cs @@ -27,142 +27,141 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Represents a layer in the drawing, containing a collection of drawable elements. +/// Uses QuadTree for spatial indexing and simple culling for performance. +/// NO BITMAP TILING. +/// +public class Layer : ReactiveObject { - /// - /// Represents a layer in the drawing, containing a collection of drawable elements. - /// Uses QuadTree for spatial indexing and simple culling for performance. - /// NO BITMAP TILING. - /// - public class Layer : ReactiveObject - { - private string name = "Layer"; - private bool isVisible = true; - private bool isLocked = false; - private MaskingMode maskingMode = MaskingMode.None; + private string name = "Layer"; + private bool isVisible = true; + private bool isLocked = false; + private MaskingMode maskingMode = MaskingMode.None; - private QuadTree quadTree; + private QuadTreeMemento quadTree; - public Guid Id { get; } = Guid.NewGuid(); + public Guid Id { get; } = Guid.NewGuid(); - public Layer() - { - // Initialize QuadTree with large bounds (arbitrary large world) - var worldBounds = new SKRect(-500000, -500000, 500000, 500000); - quadTree = new QuadTree(0, worldBounds, e => e.Bounds); + public Layer() + { + // Initialize QuadTree with large bounds (arbitrary large world) + var worldBounds = new SKRect(-500000, -500000, 500000, 500000); + quadTree = new QuadTreeMemento(0, worldBounds, e => e.Bounds); - Elements.CollectionChanged += OnElementsCollectionChanged; - } + Elements.CollectionChanged += OnElementsCollectionChanged; + } - public string Name - { - get => name; - set => this.RaiseAndSetIfChanged(ref name, value); - } + public string Name + { + get => name; + set => this.RaiseAndSetIfChanged(ref name, value); + } - public ObservableCollection Elements { get; } = []; + public ObservableCollection Elements { get; } = []; - public bool IsVisible - { - get => isVisible; - set => this.RaiseAndSetIfChanged(ref isVisible, value); - } + public bool IsVisible + { + get => isVisible; + set => this.RaiseAndSetIfChanged(ref isVisible, value); + } - public bool IsLocked - { - get => isLocked; - set => this.RaiseAndSetIfChanged(ref isLocked, value); - } + public bool IsLocked + { + get => isLocked; + set => this.RaiseAndSetIfChanged(ref isLocked, value); + } - public MaskingMode MaskingMode - { - get => maskingMode; - set => this.RaiseAndSetIfChanged(ref maskingMode, value); - } + public MaskingMode MaskingMode + { + get => maskingMode; + set => this.RaiseAndSetIfChanged(ref maskingMode, value); + } - private void OnElementsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private void OnElementsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems != null) { - if (e.NewItems != null) + // Find the maximum existing ZIndex in the layer from elements whose ZIndex is not 0 + // This caters to cases where ZIndex might have been explicitly set (non-zero) + int maxZIndex = -1; // Default to -1 so first element gets ZIndex 0 + if (Elements.Any()) { - // Find the maximum existing ZIndex in the layer from elements whose ZIndex is not 0 - // This caters to cases where ZIndex might have been explicitly set (non-zero) - int maxZIndex = -1; // Default to -1 so first element gets ZIndex 0 - if (Elements.Any()) - { - maxZIndex = Elements.Where(el => !e.NewItems.Contains(el)) // Exclude newly added items themselves - .DefaultIfEmpty(new DrawablePath { ZIndex = -1, Path = new SKPath() }) // Provide a default if no other elements - .Max(el => el.ZIndex); - } + maxZIndex = Elements.Where(el => !e.NewItems.Contains(el)) // Exclude newly added items themselves + .DefaultIfEmpty(new DrawablePath { ZIndex = -1, Path = new SKPath() }) // Provide a default if no other elements + .Max(el => el.ZIndex); + } - foreach (IDrawableElement item in e.NewItems) + foreach (IDrawableElement item in e.NewItems) + { + // Only assign ZIndex if it hasn't been explicitly set (i.e., it's default 0) + if (item.ZIndex == 0) { - // Only assign ZIndex if it hasn't been explicitly set (i.e., it's default 0) - if (item.ZIndex == 0) - { - item.ZIndex = maxZIndex + 1; // Assign a ZIndex higher than any existing element - maxZIndex = item.ZIndex; // Update maxZIndex for subsequent new items in this batch - } + item.ZIndex = maxZIndex + 1; // Assign a ZIndex higher than any existing element + maxZIndex = item.ZIndex; // Update maxZIndex for subsequent new items in this batch } } - - RebuildQuadTree(); } - private void RebuildQuadTree() - { - quadTree.Clear(); - foreach (var element in Elements) - { - quadTree.Insert(element); - } - } + RebuildQuadTree(); + } - public static void InvalidateCache() + private void RebuildQuadTree() + { + quadTree.Clear(); + foreach (var element in Elements) { - // No cache to invalidate + quadTree.Insert(element); } + } - public void Draw(SKCanvas canvas) - { - // Get visible rect in World Coordinates (Local Clip Bounds handles the matrix transform automatically) - var visibleRect = canvas.LocalClipBounds; + public static void InvalidateCache() + { + // No cache to invalidate + } + + public void Draw(SKCanvas canvas) + { + // Get visible rect in World Coordinates (Local Clip Bounds handles the matrix transform automatically) + var visibleRect = canvas.LocalClipBounds; - // Use QuadTree to find elements that are potentially visible - var visibleElements = new List(); - quadTree.Retrieve(visibleElements, visibleRect); + // Use QuadTree to find elements that are potentially visible + var visibleElements = new List(); + quadTree.Retrieve(visibleElements, visibleRect); - // Sort by ZIndex to ensure correct draw order - visibleElements.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex)); + // Sort by ZIndex to ensure correct draw order + visibleElements.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex)); - foreach (var element in visibleElements) + foreach (var element in visibleElements) + { + if (element.IsVisible) { - if (element.IsVisible) + // Double check intersection just in case QuadTree is loose + if (element.Bounds.IntersectsWith(visibleRect)) { - // Double check intersection just in case QuadTree is loose - if (element.Bounds.IntersectsWith(visibleRect)) - { - element.Draw(canvas); - } + element.Draw(canvas); } } } + } - public Layer Clone() + public Layer Clone() + { + var clone = new Layer { - var clone = new Layer - { - Name = Name, - IsVisible = IsVisible, - IsLocked = IsLocked, - MaskingMode = MaskingMode - }; - - foreach (var element in Elements) - { - clone.Elements.Add(element.Clone()); - } + Name = Name, + IsVisible = IsVisible, + IsLocked = IsLocked, + MaskingMode = MaskingMode + }; - return clone; + foreach (var element in Elements) + { + clone.Elements.Add(element.Clone()); } + + return clone; } } diff --git a/Logic/Models/MaskingMode.cs b/Logic/Models/MaskingMode.cs index b354fcf..4a2ac28 100644 --- a/Logic/Models/MaskingMode.cs +++ b/Logic/Models/MaskingMode.cs @@ -21,12 +21,11 @@ * */ -namespace LunaDraw.Logic.Models -{ - public enum MaskingMode - { - None, - Clip, - Alpha - } -} +namespace LunaDraw.Logic.Models; + + public enum MaskingMode + { + None, + Clip, + Alpha + } diff --git a/Logic/Models/NavigationModel.cs b/Logic/Models/NavigationModel.cs index 8f91cdb..eb0c2b1 100644 --- a/Logic/Models/NavigationModel.cs +++ b/Logic/Models/NavigationModel.cs @@ -24,36 +24,35 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Models -{ - public class NavigationModel : ReactiveObject - { - private SKMatrix viewMatrix = SKMatrix.CreateIdentity(); +namespace LunaDraw.Logic.Models; - // Single source of truth - this is what gets applied to the canvas - public SKMatrix ViewMatrix - { - get => viewMatrix; - set => this.RaiseAndSetIfChanged(ref viewMatrix, value); - } + public class NavigationModel : ReactiveObject + { + private SKMatrix viewMatrix = SKMatrix.CreateIdentity(); - private float canvasWidth; - public float CanvasWidth - { - get => canvasWidth; - set => this.RaiseAndSetIfChanged(ref canvasWidth, value); - } + // Single source of truth - this is what gets applied to the canvas + public SKMatrix ViewMatrix + { + get => viewMatrix; + set => this.RaiseAndSetIfChanged(ref viewMatrix, value); + } - private float canvasHeight; - public float CanvasHeight - { - get => canvasHeight; - set => this.RaiseAndSetIfChanged(ref canvasHeight, value); - } + private float canvasWidth; + public float CanvasWidth + { + get => canvasWidth; + set => this.RaiseAndSetIfChanged(ref canvasWidth, value); + } - public void Reset() - { - ViewMatrix = SKMatrix.CreateIdentity(); - } - } -} \ No newline at end of file + private float canvasHeight; + public float CanvasHeight + { + get => canvasHeight; + set => this.RaiseAndSetIfChanged(ref canvasHeight, value); + } + + public void Reset() + { + ViewMatrix = SKMatrix.CreateIdentity(); + } + } \ No newline at end of file diff --git a/Logic/Models/ToolContext.cs b/Logic/Models/ToolContext.cs index f647069..24c99c3 100644 --- a/Logic/Models/ToolContext.cs +++ b/Logic/Models/ToolContext.cs @@ -21,36 +21,35 @@ * */ -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using SkiaSharp; -namespace LunaDraw.Logic.Models +namespace LunaDraw.Logic.Models; + +/// +/// Provides context information to drawing tools, including current drawing properties and access to elements. +/// +public class ToolContext { - /// - /// Provides context information to drawing tools, including current drawing properties and access to elements. - /// - public class ToolContext - { - public required Layer CurrentLayer { get; set; } - public SKColor StrokeColor { get; set; } - public SKColor? FillColor { get; set; } - public float StrokeWidth { get; set; } - public byte Opacity { get; set; } - public byte Flow { get; set; } - public float Spacing { get; set; } - public required BrushShape BrushShape { get; set; } - public required IEnumerable AllElements { get; set; } - public IEnumerable Layers { get; set; } = []; - public required SelectionObserver SelectionObserver { get; set; } - public float Scale { get; set; } = 1.0f; - public bool IsGlowEnabled { get; init; } - public SKColor GlowColor { get; init; } - public float GlowRadius { get; init; } - public bool IsRainbowEnabled { get; init; } - public float ScatterRadius { get; init; } - public float SizeJitter { get; init; } - public float AngleJitter { get; init; } - public float HueJitter { get; init; } - public SKMatrix CanvasMatrix { get; set; } = SKMatrix.CreateIdentity(); - } + public required Layer CurrentLayer { get; set; } + public SKColor StrokeColor { get; set; } + public SKColor? FillColor { get; set; } + public float StrokeWidth { get; set; } + public byte Opacity { get; set; } + public byte Flow { get; set; } + public float Spacing { get; set; } + public required BrushShape BrushShape { get; set; } + public required IEnumerable AllElements { get; set; } + public IEnumerable Layers { get; set; } = []; + public required SelectionObserver SelectionObserver { get; set; } + public float Scale { get; set; } = 1.0f; + public bool IsGlowEnabled { get; init; } + public SKColor GlowColor { get; init; } + public float GlowRadius { get; init; } + public bool IsRainbowEnabled { get; init; } + public float ScatterRadius { get; init; } + public float SizeJitter { get; init; } + public float AngleJitter { get; init; } + public float HueJitter { get; init; } + public SKMatrix CanvasMatrix { get; set; } = SKMatrix.CreateIdentity(); } diff --git a/Logic/Services/IPreferencesService.cs b/Logic/Services/IPreferencesService.cs new file mode 100644 index 0000000..8762798 --- /dev/null +++ b/Logic/Services/IPreferencesService.cs @@ -0,0 +1,7 @@ +namespace LunaDraw.Logic.Services; + +public interface IPreferencesService +{ + bool Get(string key, bool defaultValue); + void Set(string key, bool value); +} diff --git a/Logic/Services/PreferencesService.cs b/Logic/Services/PreferencesService.cs new file mode 100644 index 0000000..a334e03 --- /dev/null +++ b/Logic/Services/PreferencesService.cs @@ -0,0 +1,9 @@ +using Microsoft.Maui.Storage; + +namespace LunaDraw.Logic.Services; + +public class PreferencesService : IPreferencesService +{ + public bool Get(string key, bool defaultValue) => Preferences.Get(key, defaultValue); + public void Set(string key, bool value) => Preferences.Set(key, value); +} diff --git a/Logic/Tools/EllipseTool.cs b/Logic/Tools/EllipseTool.cs index 2399c71..31f6a70 100644 --- a/Logic/Tools/EllipseTool.cs +++ b/Logic/Tools/EllipseTool.cs @@ -25,33 +25,32 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class EllipseTool(IMessageBus messageBus) : ShapeTool(messageBus) { - public class EllipseTool(IMessageBus messageBus) : ShapeTool(messageBus) - { - public override string Name => "Ellipse"; - public override ToolType Type => ToolType.Ellipse; + public override string Name => "Ellipse"; + public override ToolType Type => ToolType.Ellipse; - protected override DrawableEllipse CreateShape(ToolContext context) + protected override DrawableEllipse CreateShape(ToolContext context) + { + return new DrawableEllipse { - return new DrawableEllipse - { - StrokeColor = context.StrokeColor, - StrokeWidth = context.StrokeWidth, - Opacity = context.Opacity, - FillColor = context.FillColor - }; - } + StrokeColor = context.StrokeColor, + StrokeWidth = context.StrokeWidth, + Opacity = context.Opacity, + FillColor = context.FillColor + }; + } - protected override void UpdateShape(DrawableEllipse shape, SKRect bounds, SKMatrix transform) - { - shape.TransformMatrix = transform; - shape.Oval = bounds; - } + protected override void UpdateShape(DrawableEllipse shape, SKRect bounds, SKMatrix transform) + { + shape.TransformMatrix = transform; + shape.Oval = bounds; + } - protected override bool IsShapeValid(DrawableEllipse shape) - { - return shape.Oval.Width > 0 || shape.Oval.Height > 0; - } + protected override bool IsShapeValid(DrawableEllipse shape) + { + return shape.Oval.Width > 0 || shape.Oval.Height > 0; } } \ No newline at end of file diff --git a/Logic/Tools/EraserBrushTool.cs b/Logic/Tools/EraserBrushTool.cs index a799448..bd9b6b1 100644 --- a/Logic/Tools/EraserBrushTool.cs +++ b/Logic/Tools/EraserBrushTool.cs @@ -28,342 +28,341 @@ using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class EraserBrushTool(IMessageBus messageBus) : IDrawingTool { - public class EraserBrushTool(IMessageBus messageBus) : IDrawingTool + public string Name => "Eraser"; + public ToolType Type => ToolType.Eraser; + + private SKPath? currentPath; + private DrawablePath? currentDrawablePath; + private readonly IMessageBus messageBus = messageBus; + + public void OnTouchPressed(SKPoint point, ToolContext context) { - public string Name => "Eraser"; - public ToolType Type => ToolType.Eraser; + if (context.CurrentLayer?.IsLocked == true) return; - private SKPath? currentPath; - private DrawablePath? currentDrawablePath; - private readonly IMessageBus messageBus = messageBus; + currentPath = new SKPath(); + currentPath.MoveTo(point); - public void OnTouchPressed(SKPoint point, ToolContext context) + currentDrawablePath = new DrawablePath { - if (context.CurrentLayer?.IsLocked == true) return; + Path = currentPath, + StrokeColor = SKColors.White, // Visual preview color + StrokeWidth = context.StrokeWidth * 2, // Eraser usually wider + Opacity = 255, + BlendMode = SKBlendMode.SrcOver, + ZIndex = context.CurrentLayer?.Elements.Count > 0 ? context.CurrentLayer.Elements.Max(e => e.ZIndex) + 1 : 0 + }; + + context.CurrentLayer?.Elements.Add(currentDrawablePath); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - currentPath = new SKPath(); - currentPath.MoveTo(point); + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (currentPath == null || context.CurrentLayer?.IsLocked == true) return; - currentDrawablePath = new DrawablePath - { - Path = currentPath, - StrokeColor = SKColors.White, // Visual preview color - StrokeWidth = context.StrokeWidth * 2, // Eraser usually wider - Opacity = 255, - BlendMode = SKBlendMode.SrcOver, - ZIndex = context.CurrentLayer?.Elements.Count > 0 ? context.CurrentLayer.Elements.Max(e => e.ZIndex) + 1 : 0 - }; - - context.CurrentLayer?.Elements.Add(currentDrawablePath); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + currentPath.LineTo(point); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void OnTouchMoved(SKPoint point, ToolContext context) - { - if (currentPath == null || context.CurrentLayer?.IsLocked == true) return; + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (currentPath == null || context.CurrentLayer == null) return; - currentPath.LineTo(point); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + currentPath.LineTo(point); - public void OnTouchReleased(SKPoint point, ToolContext context) + // Remove the temporary "global" eraser path + if (currentDrawablePath != null) { - if (currentPath == null || context.CurrentLayer == null) return; + context.CurrentLayer.Elements.Remove(currentDrawablePath); + } - currentPath.LineTo(point); + // Convert stroke to fill path (outline) for operations + using var strokePaint = new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = context.StrokeWidth * 2, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + using var eraserOutline = new SKPath(); + strokePaint.GetFillPath(currentPath, eraserOutline); + + var elements = context.CurrentLayer.Elements.ToList(); + var modified = false; + var elementsToRemove = new List(); + var elementsToAdd = new List(); + + foreach (var element in elements) + { + if (element == currentDrawablePath) continue; + if (!element.IsVisible) continue; - // Remove the temporary "global" eraser path - if (currentDrawablePath != null) + // Determine if this is a "pure stroke" (like a freehand line or line shape) vs a "filled shape" + bool isPureStroke; + if (element is DrawablePath dp) { - context.CurrentLayer.Elements.Remove(currentDrawablePath); + // A DrawablePath is a pure stroke only if it is explicitly NOT filled. + // If it has a FillShader (e.g. erased image) or IsFilled=true, it is a shape. + isPureStroke = !dp.IsFilled; } - - // Convert stroke to fill path (outline) for operations - using var strokePaint = new SKPaint + else if (element is DrawableLine) { - Style = SKPaintStyle.Stroke, - StrokeWidth = context.StrokeWidth * 2, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round - }; - using var eraserOutline = new SKPath(); - strokePaint.GetFillPath(currentPath, eraserOutline); - - var elements = context.CurrentLayer.Elements.ToList(); - var modified = false; - var elementsToRemove = new List(); - var elementsToAdd = new List(); - - foreach (var element in elements) + isPureStroke = true; + } + else if (element is DrawableImage) { - if (element == currentDrawablePath) continue; - if (!element.IsVisible) continue; + isPureStroke = false; + } + else + { + // For other shapes (Rect, Ellipse), they are pure strokes if they have no fill. + isPureStroke = element.FillColor == null; + } - // Determine if this is a "pure stroke" (like a freehand line or line shape) vs a "filled shape" - bool isPureStroke; - if (element is DrawablePath dp) - { - // A DrawablePath is a pure stroke only if it is explicitly NOT filled. - // If it has a FillShader (e.g. erased image) or IsFilled=true, it is a shape. - isPureStroke = !dp.IsFilled; - } - else if (element is DrawableLine) - { - isPureStroke = true; - } - else if (element is DrawableImage) - { - isPureStroke = false; - } - else - { - // For other shapes (Rect, Ellipse), they are pure strokes if they have no fill. - isPureStroke = element.FillColor == null; - } + if (element is DrawableStamps stamps) + { + var remainingPoints = new List(); + var remainingRotations = new List(); + var stampModified = false; - if (element is DrawableStamps stamps) - { - var remainingPoints = new List(); - var remainingRotations = new List(); - var stampModified = false; + // Iterate through detailed instances to handle geometry & color accurately + var instances = stamps.GetDetailedPaths().ToList(); - // Iterate through detailed instances to handle geometry & color accurately - var instances = stamps.GetDetailedPaths().ToList(); + // We need to match instances back to original points by index + for (int i = 0; i < instances.Count; i++) + { + var (stampPath, stampColor) = instances[i]; + var originalPoint = stamps.Points[i]; - // We need to match instances back to original points by index - for (int i = 0; i < instances.Count; i++) + using (stampPath) { - var (stampPath, stampColor) = instances[i]; - var originalPoint = stamps.Points[i]; + // Check intersection + using var intersection = new SKPath(); + bool intersects = eraserOutline.Op(stampPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty; - using (stampPath) + if (!intersects) { - // Check intersection - using var intersection = new SKPath(); - bool intersects = eraserOutline.Op(stampPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty; - - if (!intersects) + // Completely untouched, keep as a stamp + remainingPoints.Add(originalPoint); + if (stamps.Rotations != null && i < stamps.Rotations.Count) { - // Completely untouched, keep as a stamp - remainingPoints.Add(originalPoint); - if (stamps.Rotations != null && i < stamps.Rotations.Count) - { - remainingRotations.Add(stamps.Rotations[i]); - } + remainingRotations.Add(stamps.Rotations[i]); } - else - { - // Touched (Partial or Full erase) -> Convert to Path(s) or Destroy - stampModified = true; + } + else + { + // Touched (Partial or Full erase) -> Convert to Path(s) or Destroy + stampModified = true; - using var resultPath = new SKPath(); - if (stampPath.Op(eraserOutline, SKPathOp.Difference, resultPath) && !resultPath.IsEmpty) + using var resultPath = new SKPath(); + if (stampPath.Op(eraserOutline, SKPathOp.Difference, resultPath) && !resultPath.IsEmpty) + { + // Create a new DrawablePath for the fragment + var newFragment = new DrawablePath { - // Create a new DrawablePath for the fragment - var newFragment = new DrawablePath - { - Path = new SKPath(resultPath), // Copy the result - TransformMatrix = SKMatrix.CreateIdentity(), // Logic was already applied in GetDetailedPaths (including TransformMatrix) - IsVisible = stamps.IsVisible, - Opacity = (byte)(stamps.Opacity * stamps.Flow / 255f), // Combine Opacity and Flow - ZIndex = stamps.ZIndex, - IsSelected = false, // Fragments shouldn't inherit selection immediately - IsGlowEnabled = stamps.IsGlowEnabled, - GlowColor = stamps.GlowColor, - GlowRadius = stamps.GlowRadius, - IsFilled = true, // Stamps are filled shapes - StrokeWidth = 0, - StrokeColor = stampColor, // Use the specific jittered color - FillColor = null, // Logic implies "Filled Stroke" behavior for consistency with other paths - BlendMode = stamps.BlendMode - }; - - // Note: 'stampColor' is used as StrokeColor with IsFilled=true because - // in the generic path logic above (lines 169-172), eroded strokes become filled blobs - // where StrokeColor is preserved. DrawableStamps usually render as fills of the 'StrokeColor'. - - elementsToAdd.Add(newFragment); - } - // If resultPath is empty, it was fully erased. Do nothing (it's gone). + Path = new SKPath(resultPath), // Copy the result + TransformMatrix = SKMatrix.CreateIdentity(), // Logic was already applied in GetDetailedPaths (including TransformMatrix) + IsVisible = stamps.IsVisible, + Opacity = (byte)(stamps.Opacity * stamps.Flow / 255f), // Combine Opacity and Flow + ZIndex = stamps.ZIndex, + IsSelected = false, // Fragments shouldn't inherit selection immediately + IsGlowEnabled = stamps.IsGlowEnabled, + GlowColor = stamps.GlowColor, + GlowRadius = stamps.GlowRadius, + IsFilled = true, // Stamps are filled shapes + StrokeWidth = 0, + StrokeColor = stampColor, // Use the specific jittered color + FillColor = null, // Logic implies "Filled Stroke" behavior for consistency with other paths + BlendMode = stamps.BlendMode + }; + + // Note: 'stampColor' is used as StrokeColor with IsFilled=true because + // in the generic path logic above (lines 169-172), eroded strokes become filled blobs + // where StrokeColor is preserved. DrawableStamps usually render as fills of the 'StrokeColor'. + + elementsToAdd.Add(newFragment); } + // If resultPath is empty, it was fully erased. Do nothing (it's gone). } } + } - if (stampModified) + if (stampModified) + { + if (remainingPoints.Count == 0) { - if (remainingPoints.Count == 0) - { - elementsToRemove.Add(element); - } - else - { - // Create a new Stamps object for the remaining untouched stamps - var newStamps = (DrawableStamps)stamps.Clone(); - newStamps.Points = remainingPoints; - newStamps.Rotations = remainingRotations; + elementsToRemove.Add(element); + } + else + { + // Create a new Stamps object for the remaining untouched stamps + var newStamps = (DrawableStamps)stamps.Clone(); + newStamps.Points = remainingPoints; + newStamps.Rotations = remainingRotations; - elementsToRemove.Add(element); - elementsToAdd.Add(newStamps); - } - modified = true; + elementsToRemove.Add(element); + elementsToAdd.Add(newStamps); } - continue; + modified = true; } + continue; + } - SKPath elementPath; - if (isPureStroke) - { - // OLD BEHAVIOR for strokes: Get the visual outline - elementPath = element.GetPath(); - } - else - { - // NEW BEHAVIOR for shapes: Get the geometry contour - elementPath = element.GetGeometryPath(); - } + SKPath elementPath; + if (isPureStroke) + { + // OLD BEHAVIOR for strokes: Get the visual outline + elementPath = element.GetPath(); + } + else + { + // NEW BEHAVIOR for shapes: Get the geometry contour + elementPath = element.GetGeometryPath(); + } - using (elementPath) + using (elementPath) + { + // Check for intersection first (optimization) + using var intersection = new SKPath(); + if (eraserOutline.Op(elementPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty) { - // Check for intersection first (optimization) - using var intersection = new SKPath(); - if (eraserOutline.Op(elementPath, SKPathOp.Intersect, intersection) && !intersection.IsEmpty) + // Calculate the difference (Element - Eraser) + var resultPath = new SKPath(); + if (elementPath.Op(eraserOutline, SKPathOp.Difference, resultPath)) { - // Calculate the difference (Element - Eraser) - var resultPath = new SKPath(); - if (elementPath.Op(eraserOutline, SKPathOp.Difference, resultPath)) + if (resultPath.IsEmpty) { - if (resultPath.IsEmpty) + // Element completely erased + elementsToRemove.Add(element); + } + else + { + // Create new element with the remaining geometry + var newElement = new DrawablePath + { + Path = resultPath, + TransformMatrix = SKMatrix.CreateIdentity(), // We might need to adjust this depending on how GetGeometryPath works + IsVisible = element.IsVisible, + Opacity = element.Opacity, + ZIndex = element.ZIndex, + IsSelected = element.IsSelected, + IsGlowEnabled = element.IsGlowEnabled, + GlowColor = element.GlowColor, + GlowRadius = element.GlowRadius + }; + + if (isPureStroke) { - // Element completely erased - elementsToRemove.Add(element); + // Result of eroding a stroke is a filled shape (the leftover pieces of the outline) + newElement.StrokeWidth = 0; + newElement.IsFilled = true; + newElement.StrokeColor = element.StrokeColor; + newElement.FillColor = null; } else { - // Create new element with the remaining geometry - var newElement = new DrawablePath - { - Path = resultPath, - TransformMatrix = SKMatrix.CreateIdentity(), // We might need to adjust this depending on how GetGeometryPath works - IsVisible = element.IsVisible, - Opacity = element.Opacity, - ZIndex = element.ZIndex, - IsSelected = element.IsSelected, - IsGlowEnabled = element.IsGlowEnabled, - GlowColor = element.GlowColor, - GlowRadius = element.GlowRadius - }; - - if (isPureStroke) + // Result of eroding a shape preserves shape properties + newElement.StrokeWidth = element.StrokeWidth; + newElement.StrokeColor = element.StrokeColor; + newElement.FillColor = element.FillColor; + newElement.IsFilled = true; + + // TRANSFORM FIX: + // Put the path back into the original element's coordinate space + if (element.TransformMatrix.TryInvert(out var inverseMatrix)) { - // Result of eroding a stroke is a filled shape (the leftover pieces of the outline) - newElement.StrokeWidth = 0; - newElement.IsFilled = true; - newElement.StrokeColor = element.StrokeColor; - newElement.FillColor = null; + newElement.Path.Transform(inverseMatrix); + newElement.TransformMatrix = element.TransformMatrix; } - else + + // Handle Image Shader creation specifically here + if (element is DrawableImage iamge) { - // Result of eroding a shape preserves shape properties - newElement.StrokeWidth = element.StrokeWidth; - newElement.StrokeColor = element.StrokeColor; - newElement.FillColor = element.FillColor; + // Since we reverted to Local Space, the shader is simple (Identity matrix) + var shader = SKShader.CreateBitmap(iamge.Bitmap, SKShaderTileMode.Decal, SKShaderTileMode.Decal); + newElement.FillShader = shader; newElement.IsFilled = true; - // TRANSFORM FIX: - // Put the path back into the original element's coordinate space - if (element.TransformMatrix.TryInvert(out var inverseMatrix)) - { - newElement.Path.Transform(inverseMatrix); - newElement.TransformMatrix = element.TransformMatrix; - } - - // Handle Image Shader creation specifically here - if (element is DrawableImage iamge) - { - // Since we reverted to Local Space, the shader is simple (Identity matrix) - var shader = SKShader.CreateBitmap(iamge.Bitmap, SKShaderTileMode.Decal, SKShaderTileMode.Decal); - newElement.FillShader = shader; - newElement.IsFilled = true; - - // Ensure we carry over opacity and stuff - newElement.Opacity = iamge.Opacity; - } - else if (element is DrawablePath oldPath) - { - newElement.FillShader = oldPath.FillShader; - } + // Ensure we carry over opacity and stuff + newElement.Opacity = iamge.Opacity; } - - if (element is DrawablePath originalPath) + else if (element is DrawablePath oldPath) { - newElement.BlendMode = originalPath.BlendMode; + newElement.FillShader = oldPath.FillShader; } + } - elementsToRemove.Add(element); - elementsToAdd.Add(newElement); + if (element is DrawablePath originalPath) + { + newElement.BlendMode = originalPath.BlendMode; } - modified = true; + + elementsToRemove.Add(element); + elementsToAdd.Add(newElement); } + modified = true; } } } + } - if (modified) - { - var finalElements = new List(); + if (modified) + { + var finalElements = new List(); - // Add all elements that were NOT removed - foreach (var element in elements) // 'elements' is a copy from the start of the method + // Add all elements that were NOT removed + foreach (var element in elements) // 'elements' is a copy from the start of the method + { + if (!elementsToRemove.Contains(element)) { - if (!elementsToRemove.Contains(element)) - { - finalElements.Add(element); - } + finalElements.Add(element); } + } - // Add all newly created elements (fragments from erased items) - finalElements.AddRange(elementsToAdd); + // Add all newly created elements (fragments from erased items) + finalElements.AddRange(elementsToAdd); - // Clear the ObservableCollection once, then re-populate it - context.CurrentLayer.Elements.Clear(); - foreach (var item in finalElements.OrderBy(e => e.ZIndex)) // Add in sorted ZIndex order - { - context.CurrentLayer.Elements.Add(item); - } - - // Normalize Z-indices on the actual collection elements *after* they are in the collection - var currentLayerElementsInCollection = context.CurrentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); - for (int i = 0; i < currentLayerElementsInCollection.Count; i++) - { - currentLayerElementsInCollection[i].ZIndex = i; - } - - messageBus.SendMessage(new DrawingStateChangedMessage()); - messageBus.SendMessage(new CanvasInvalidateMessage()); + // Clear the ObservableCollection once, then re-populate it + context.CurrentLayer.Elements.Clear(); + foreach (var item in finalElements.OrderBy(e => e.ZIndex)) // Add in sorted ZIndex order + { + context.CurrentLayer.Elements.Add(item); } - messageBus.SendMessage(new CanvasInvalidateMessage()); - currentPath = null; - currentDrawablePath = null; - } - - public void OnTouchCancelled(ToolContext context) - { - if (currentDrawablePath != null && context.CurrentLayer != null) + // Normalize Z-indices on the actual collection elements *after* they are in the collection + var currentLayerElementsInCollection = context.CurrentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); + for (int i = 0; i < currentLayerElementsInCollection.Count; i++) { - context.CurrentLayer.Elements.Remove(currentDrawablePath); + currentLayerElementsInCollection[i].ZIndex = i; } - currentPath = null; - currentDrawablePath = null; + messageBus.SendMessage(new DrawingStateChangedMessage()); messageBus.SendMessage(new CanvasInvalidateMessage()); } + messageBus.SendMessage(new CanvasInvalidateMessage()); - public void DrawPreview(SKCanvas canvas, ToolContext context) + currentPath = null; + currentDrawablePath = null; + } + + public void OnTouchCancelled(ToolContext context) + { + if (currentDrawablePath != null && context.CurrentLayer != null) { - // Optional: Draw a circle cursor for eraser size + context.CurrentLayer.Elements.Remove(currentDrawablePath); } + + currentPath = null; + currentDrawablePath = null; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + // Optional: Draw a circle cursor for eraser size } } \ No newline at end of file diff --git a/Logic/Tools/EraserTool.cs b/Logic/Tools/EraserTool.cs index 2085330..af5e4f5 100644 --- a/Logic/Tools/EraserTool.cs +++ b/Logic/Tools/EraserTool.cs @@ -23,65 +23,63 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class EraserTool(IMessageBus messageBus) : IDrawingTool { - public class EraserTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Eraser"; - public ToolType Type => ToolType.Eraser; + public string Name => "Eraser"; + public ToolType Type => ToolType.Eraser; - private bool isErasing; - private readonly IMessageBus messageBus = messageBus; + private bool isErasing; + private readonly IMessageBus messageBus = messageBus; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - isErasing = true; - Erase(point, context); - } - - public void OnTouchMoved(SKPoint point, ToolContext context) - { - if (isErasing) - { - Erase(point, context); - } - } + public void OnTouchPressed(SKPoint point, ToolContext context) + { + isErasing = true; + Erase(point, context); + } - public void OnTouchReleased(SKPoint point, ToolContext context) + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (isErasing) { - isErasing = false; + Erase(point, context); } + } - public void OnTouchCancelled(ToolContext context) - { - isErasing = false; - } + public void OnTouchReleased(SKPoint point, ToolContext context) + { + isErasing = false; + } - private void Erase(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + public void OnTouchCancelled(ToolContext context) + { + isErasing = false; + } - var hitElement = context.AllElements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); + private void Erase(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - if (hitElement != null && context.CurrentLayer != null) - { - context.CurrentLayer.Elements.Remove(hitElement); - messageBus.SendMessage(new DrawingStateChangedMessage()); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - } + var hitElement = context.AllElements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); - public void DrawPreview(SKCanvas canvas, ToolContext context) + if (hitElement != null && context.CurrentLayer != null) { + context.CurrentLayer.Elements.Remove(hitElement); + messageBus.SendMessage(new DrawingStateChangedMessage()); + messageBus.SendMessage(new CanvasInvalidateMessage()); } } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + } } \ No newline at end of file diff --git a/Logic/Tools/FillTool.cs b/Logic/Tools/FillTool.cs index f3c3a18..d107858 100644 --- a/Logic/Tools/FillTool.cs +++ b/Logic/Tools/FillTool.cs @@ -23,51 +23,49 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools -{ - public class FillTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Fill"; - public ToolType Type => ToolType.Fill; - private readonly IMessageBus messageBus = messageBus; +namespace LunaDraw.Logic.Tools; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; +public class FillTool(IMessageBus messageBus) : IDrawingTool +{ + public string Name => "Fill"; + public ToolType Type => ToolType.Fill; + private readonly IMessageBus messageBus = messageBus; - var hitElement = context.AllElements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); + public void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - if (hitElement != null) - { - hitElement.FillColor = context.FillColor; - messageBus.SendMessage(new CanvasInvalidateMessage()); - messageBus.SendMessage(new DrawingStateChangedMessage()); - } - } + var hitElement = context.AllElements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); - public void OnTouchMoved(SKPoint point, ToolContext context) + if (hitElement != null) { + hitElement.FillColor = context.FillColor; + messageBus.SendMessage(new CanvasInvalidateMessage()); + messageBus.SendMessage(new DrawingStateChangedMessage()); } + } - public void OnTouchReleased(SKPoint point, ToolContext context) - { - } + public void OnTouchMoved(SKPoint point, ToolContext context) + { + } - public void OnTouchCancelled(ToolContext context) - { - } + public void OnTouchReleased(SKPoint point, ToolContext context) + { + } - public void DrawPreview(SKCanvas canvas, ToolContext context) - { - } + public void OnTouchCancelled(ToolContext context) + { + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { } } diff --git a/Logic/Tools/FreehandTool.cs b/Logic/Tools/FreehandTool.cs index 80cc933..e8eda5a 100644 --- a/Logic/Tools/FreehandTool.cs +++ b/Logic/Tools/FreehandTool.cs @@ -23,228 +23,226 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class FreehandTool(IMessageBus messageBus) : IDrawingTool { - public class FreehandTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Stamps"; - public ToolType Type => ToolType.Freehand; + public string Name => "Stamps"; + public ToolType Type => ToolType.Freehand; - private List<(SKPoint Point, float Rotation)>? currentPoints; - private SKPoint lastStampPoint; - private bool isDrawing; - private readonly Random random = new Random(); - private readonly IMessageBus messageBus = messageBus; + private List<(SKPoint Point, float Rotation)>? currentPoints; + private SKPoint lastStampPoint; + private bool isDrawing; + private readonly Random random = new Random(); + private readonly IMessageBus messageBus = messageBus; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + public void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - currentPoints = - [ - // Add initial point with default 0 rotation - (point, 0f), - ]; - lastStampPoint = point; - isDrawing = true; + currentPoints = + [ + // Add initial point with default 0 rotation + (point, 0f), + ]; + lastStampPoint = point; + isDrawing = true; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void OnTouchMoved(SKPoint point, ToolContext context) - { - if (!isDrawing || context.CurrentLayer?.IsLocked == true || currentPoints == null) return; + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (!isDrawing || context.CurrentLayer?.IsLocked == true || currentPoints == null) return; - float spacingPixels = context.Spacing * context.StrokeWidth; - if (spacingPixels < 1) spacingPixels = 1; + float spacingPixels = context.Spacing * context.StrokeWidth; + if (spacingPixels < 1) spacingPixels = 1; - var vector = point - lastStampPoint; - float distance = vector.Length; + var vector = point - lastStampPoint; + float distance = vector.Length; - if (distance >= spacingPixels) + if (distance >= spacingPixels) + { + var direction = vector; + // Normalize manually to avoid issues with zero length + if (distance > 0) { - var direction = vector; - // Normalize manually to avoid issues with zero length - if (distance > 0) - { - float invLength = 1.0f / distance; - direction = new SKPoint(direction.X * invLength, direction.Y * invLength); - } + float invLength = 1.0f / distance; + direction = new SKPoint(direction.X * invLength, direction.Y * invLength); + } - // Calculate angle for this segment - float angle = (float)(Math.Atan2(vector.Y, vector.X) * 180.0 / Math.PI); + // Calculate angle for this segment + float angle = (float)(Math.Atan2(vector.Y, vector.X) * 180.0 / Math.PI); + + int steps = (int)(distance / spacingPixels); + for (int i = 0; i < steps; i++) + { + var idealPoint = lastStampPoint + new SKPoint(direction.X * spacingPixels, direction.Y * spacingPixels); - int steps = (int)(distance / spacingPixels); - for (int i = 0; i < steps; i++) + var finalPoint = idealPoint; + if (context.ScatterRadius > 0) { - var idealPoint = lastStampPoint + new SKPoint(direction.X * spacingPixels, direction.Y * spacingPixels); - - var finalPoint = idealPoint; - if (context.ScatterRadius > 0) - { - // Random scatter in a circle - double rndAngle = random.NextDouble() * Math.PI * 2; - double r = Math.Sqrt(random.NextDouble()) * context.ScatterRadius; // Sqrt for uniform distribution - finalPoint += new SKPoint((float)(r * Math.Cos(rndAngle)), (float)(r * Math.Sin(rndAngle))); - } - - currentPoints.Add((finalPoint, angle)); - lastStampPoint = idealPoint; + // Random scatter in a circle + double rndAngle = random.NextDouble() * Math.PI * 2; + double r = Math.Sqrt(random.NextDouble()) * context.ScatterRadius; // Sqrt for uniform distribution + finalPoint += new SKPoint((float)(r * Math.Cos(rndAngle)), (float)(r * Math.Sin(rndAngle))); } - messageBus.SendMessage(new CanvasInvalidateMessage()); - + currentPoints.Add((finalPoint, angle)); + lastStampPoint = idealPoint; } + + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + } + + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (!isDrawing || context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentPoints == null) return; - public void OnTouchReleased(SKPoint point, ToolContext context) + if (currentPoints.Count > 0) { - if (!isDrawing || context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentPoints == null) return; + var points = currentPoints.Select(p => p.Point).ToList(); + var rotations = currentPoints.Select(p => p.Rotation).ToList(); - if (currentPoints.Count > 0) + var element = new DrawableStamps { - var points = currentPoints.Select(p => p.Point).ToList(); - var rotations = currentPoints.Select(p => p.Rotation).ToList(); - - var element = new DrawableStamps - { - Points = points, - Rotations = rotations, - Shape = context.BrushShape, - Size = context.StrokeWidth, - Flow = context.Flow, - Opacity = context.Opacity, - StrokeColor = context.StrokeColor, - IsGlowEnabled = context.IsGlowEnabled, - GlowColor = context.GlowColor, - GlowRadius = context.GlowRadius, - IsRainbowEnabled = context.IsRainbowEnabled, - SizeJitter = context.SizeJitter, - AngleJitter = context.AngleJitter, - HueJitter = context.HueJitter - }; - - context.CurrentLayer.Elements.Add(element); - messageBus.SendMessage(new DrawingStateChangedMessage()); - } + Points = points, + Rotations = rotations, + Shape = context.BrushShape, + Size = context.StrokeWidth, + Flow = context.Flow, + Opacity = context.Opacity, + StrokeColor = context.StrokeColor, + IsGlowEnabled = context.IsGlowEnabled, + GlowColor = context.GlowColor, + GlowRadius = context.GlowRadius, + IsRainbowEnabled = context.IsRainbowEnabled, + SizeJitter = context.SizeJitter, + AngleJitter = context.AngleJitter, + HueJitter = context.HueJitter + }; - currentPoints = null; - isDrawing = false; - messageBus.SendMessage(new CanvasInvalidateMessage()); + context.CurrentLayer.Elements.Add(element); + messageBus.SendMessage(new DrawingStateChangedMessage()); } - public void OnTouchCancelled(ToolContext context) - { - currentPoints = null; - isDrawing = false; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + currentPoints = null; + isDrawing = false; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void DrawPreview(SKCanvas canvas, ToolContext context) - { - if (currentPoints == null || currentPoints.Count == 0) return; + public void OnTouchCancelled(ToolContext context) + { + currentPoints = null; + isDrawing = false; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - // Get current shape from context - var shape = context.BrushShape; - if (shape?.Path == null) return; + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (currentPoints == null || currentPoints.Count == 0) return; - float size = context.StrokeWidth; - float baseScale = size / 20f; - byte flow = context.Flow; - byte opacity = context.Opacity; + // Get current shape from context + var shape = context.BrushShape; + if (shape?.Path == null) return; - using var scaledPath = new SKPath(shape.Path); - var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); - scaledPath.Transform(scaleMatrix); + float size = context.StrokeWidth; + float baseScale = size / 20f; + byte flow = context.Flow; + byte opacity = context.Opacity; - using var paint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true - }; + using var scaledPath = new SKPath(shape.Path); + var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); + scaledPath.Transform(scaleMatrix); - int index = 0; - - foreach (var item in currentPoints) - { - var point = item.Point; - var baseRotation = item.Rotation; + using var paint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true + }; - // Local random for preview jitter (Must match DrawableStamps logic) - int seed; - unchecked - { - seed = 17; - seed = seed * 23 + point.X.GetHashCode(); - seed = seed * 23 + point.Y.GetHashCode(); - } - var random = new Random(seed); + int index = 0; - // 1. Size Jitter (Consume randoms first) - float scaleFactor = 1.0f; - if (context.SizeJitter > 0) - { - float unusedJitter = (float)random.NextDouble() * context.SizeJitter; - scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * context.SizeJitter; - if (scaleFactor < 0.1f) scaleFactor = 0.1f; - } + foreach (var item in currentPoints) + { + var point = item.Point; + var baseRotation = item.Rotation; - // 2. Angle Jitter - float rotationDelta = 0f; - if (context.AngleJitter > 0) - { - rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * context.AngleJitter; - } + // Local random for preview jitter (Must match DrawableStamps logic) + int seed; + unchecked + { + seed = 17; + seed = seed * 23 + point.X.GetHashCode(); + seed = seed * 23 + point.Y.GetHashCode(); + } + var random = new Random(seed); - // 3. Color (Hue) Jitter - SKColor color = context.StrokeColor; - if (context.IsRainbowEnabled) - { - float hue = index * 10 % 360; - color = SKColor.FromHsl(hue, 100, 50); - } - else if (context.HueJitter > 0) - { - color.ToHsl(out float h, out float s, out float l); - float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * context.HueJitter * 360f; - h = (h + jitter) % 360f; - if (h < 0) h += 360f; - color = SKColor.FromHsl(h, s, l); - } - - paint.Color = color.WithAlpha((byte)(flow * (opacity / 255f))); + // 1. Size Jitter (Consume randoms first) + float scaleFactor = 1.0f; + if (context.SizeJitter > 0) + { + float unusedJitter = (float)random.NextDouble() * context.SizeJitter; + scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * context.SizeJitter; + if (scaleFactor < 0.1f) scaleFactor = 0.1f; + } - canvas.Save(); - canvas.Translate(point.X, point.Y); + // 2. Angle Jitter + float rotationDelta = 0f; + if (context.AngleJitter > 0) + { + rotationDelta = ((float)random.NextDouble() - 0.5f) * 2.0f * context.AngleJitter; + } - // Apply Stroke Rotation - if (Math.Abs(baseRotation) > 0.001f) - { - canvas.RotateDegrees(baseRotation); - } + // 3. Color (Hue) Jitter + SKColor color = context.StrokeColor; + if (context.IsRainbowEnabled) + { + float hue = index * 10 % 360; + color = SKColor.FromHsl(hue, 100, 50); + } + else if (context.HueJitter > 0) + { + color.ToHsl(out float h, out float s, out float l); + float jitter = ((float)random.NextDouble() - 0.5f) * 2.0f * context.HueJitter * 360f; + h = (h + jitter) % 360f; + if (h < 0) h += 360f; + color = SKColor.FromHsl(h, s, l); + } - // Apply Jitter Rotation - if (context.AngleJitter > 0) - { - canvas.RotateDegrees(rotationDelta); - } + paint.Color = color.WithAlpha((byte)(flow * (opacity / 255f))); - // Apply Jitter Scale - if (scaleFactor != 1.0f) - { - canvas.Scale(scaleFactor); - } + canvas.Save(); + canvas.Translate(point.X, point.Y); - canvas.DrawPath(scaledPath, paint); - canvas.Restore(); + // Apply Stroke Rotation + if (Math.Abs(baseRotation) > 0.001f) + { + canvas.RotateDegrees(baseRotation); + } - index++; + // Apply Jitter Rotation + if (context.AngleJitter > 0) + { + canvas.RotateDegrees(rotationDelta); } + + // Apply Jitter Scale + if (scaleFactor != 1.0f) + { + canvas.Scale(scaleFactor); + } + + canvas.DrawPath(scaledPath, paint); + canvas.Restore(); + + index++; } } } \ No newline at end of file diff --git a/Logic/Tools/IDrawingTool.cs b/Logic/Tools/IDrawingTool.cs index 0611738..9846ceb 100644 --- a/Logic/Tools/IDrawingTool.cs +++ b/Logic/Tools/IDrawingTool.cs @@ -22,33 +22,31 @@ */ using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public enum ToolType { - public enum ToolType - { - None, - Select, - Freehand, - Rectangle, - Ellipse, - Line, - Fill, - Eraser - } + None, + Select, + Freehand, + Rectangle, + Ellipse, + Line, + Fill, + Eraser +} - public interface IDrawingTool - { - string Name { get; } - ToolType Type { get; } +public interface IDrawingTool +{ + string Name { get; } + ToolType Type { get; } - void OnTouchPressed(SKPoint point, ToolContext context); - void OnTouchMoved(SKPoint point, ToolContext context); - void OnTouchReleased(SKPoint point, ToolContext context); - void OnTouchCancelled(ToolContext context); - void DrawPreview(SKCanvas canvas, ToolContext context); - } + void OnTouchPressed(SKPoint point, ToolContext context); + void OnTouchMoved(SKPoint point, ToolContext context); + void OnTouchReleased(SKPoint point, ToolContext context); + void OnTouchCancelled(ToolContext context); + void DrawPreview(SKCanvas canvas, ToolContext context); } diff --git a/Logic/Tools/LineTool.cs b/Logic/Tools/LineTool.cs index 45ed696..06371d2 100644 --- a/Logic/Tools/LineTool.cs +++ b/Logic/Tools/LineTool.cs @@ -23,73 +23,71 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class LineTool(IMessageBus messageBus) : IDrawingTool { - public class LineTool(IMessageBus messageBus) : IDrawingTool - { - public string Name => "Line"; - public ToolType Type => ToolType.Line; + public string Name => "Line"; + public ToolType Type => ToolType.Line; - private SKPoint startPoint; - private DrawableLine? currentLine; - private readonly IMessageBus messageBus = messageBus; + private SKPoint startPoint; + private DrawableLine? currentLine; + private readonly IMessageBus messageBus = messageBus; - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; - - startPoint = point; - currentLine = new DrawableLine - { - StartPoint = SKPoint.Empty, - EndPoint = SKPoint.Empty, - TransformMatrix = SKMatrix.CreateTranslation(point.X, point.Y), - StrokeColor = context.StrokeColor, - StrokeWidth = context.StrokeWidth, - Opacity = context.Opacity - }; - } + public void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - public void OnTouchMoved(SKPoint point, ToolContext context) + startPoint = point; + currentLine = new DrawableLine { - if (context.CurrentLayer?.IsLocked == true || currentLine == null) return; - - currentLine.EndPoint = point - startPoint; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + StartPoint = SKPoint.Empty, + EndPoint = SKPoint.Empty, + TransformMatrix = SKMatrix.CreateTranslation(point.X, point.Y), + StrokeColor = context.StrokeColor, + StrokeWidth = context.StrokeWidth, + Opacity = context.Opacity + }; + } - public void OnTouchReleased(SKPoint point, ToolContext context) - { - if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentLine == null) return; + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true || currentLine == null) return; - if (!currentLine.EndPoint.Equals(SKPoint.Empty)) - { - context.CurrentLayer.Elements.Add(currentLine); - messageBus.SendMessage(new DrawingStateChangedMessage()); - } + currentLine.EndPoint = point - startPoint; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - currentLine = null; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || currentLine == null) return; - public void OnTouchCancelled(ToolContext context) + if (!currentLine.EndPoint.Equals(SKPoint.Empty)) { - currentLine = null; - messageBus.SendMessage(new CanvasInvalidateMessage()); + context.CurrentLayer.Elements.Add(currentLine); + messageBus.SendMessage(new DrawingStateChangedMessage()); } - public void DrawPreview(SKCanvas canvas, ToolContext context) + currentLine = null; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void OnTouchCancelled(ToolContext context) + { + currentLine = null; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (currentLine != null) { - if (currentLine != null) - { - currentLine.Draw(canvas); - } + currentLine.Draw(canvas); } } } \ No newline at end of file diff --git a/Logic/Tools/RectangleTool.cs b/Logic/Tools/RectangleTool.cs index 02ee9c0..cf32f4e 100644 --- a/Logic/Tools/RectangleTool.cs +++ b/Logic/Tools/RectangleTool.cs @@ -25,33 +25,32 @@ using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public class RectangleTool(IMessageBus messageBus) : ShapeTool(messageBus) { - public class RectangleTool(IMessageBus messageBus) : ShapeTool(messageBus) - { - public override string Name => "Rectangle"; - public override ToolType Type => ToolType.Rectangle; + public override string Name => "Rectangle"; + public override ToolType Type => ToolType.Rectangle; - protected override DrawableRectangle CreateShape(ToolContext context) + protected override DrawableRectangle CreateShape(ToolContext context) + { + return new DrawableRectangle { - return new DrawableRectangle - { - StrokeColor = context.StrokeColor, - StrokeWidth = context.StrokeWidth, - Opacity = context.Opacity, - FillColor = context.FillColor - }; - } + StrokeColor = context.StrokeColor, + StrokeWidth = context.StrokeWidth, + Opacity = context.Opacity, + FillColor = context.FillColor + }; + } - protected override void UpdateShape(DrawableRectangle shape, SKRect bounds, SKMatrix transform) - { - shape.TransformMatrix = transform; - shape.Rectangle = bounds; - } + protected override void UpdateShape(DrawableRectangle shape, SKRect bounds, SKMatrix transform) + { + shape.TransformMatrix = transform; + shape.Rectangle = bounds; + } - protected override bool IsShapeValid(DrawableRectangle shape) - { - return shape.Rectangle.Width > 0 || shape.Rectangle.Height > 0; - } + protected override bool IsShapeValid(DrawableRectangle shape) + { + return shape.Rectangle.Width > 0 || shape.Rectangle.Height > 0; } } \ No newline at end of file diff --git a/Logic/Tools/SelectTool.cs b/Logic/Tools/SelectTool.cs index 1517bd8..8e496ce 100644 --- a/Logic/Tools/SelectTool.cs +++ b/Logic/Tools/SelectTool.cs @@ -23,272 +23,270 @@ using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools -{ - public enum SelectionState { None, Selecting, Dragging, Resizing } - public enum ResizeHandle { None, TopLeft, TopRight, BottomLeft, BottomRight, Top, Right, Bottom, Left } +namespace LunaDraw.Logic.Tools; + +public enum SelectionState { None, Selecting, Dragging, Resizing } +public enum ResizeHandle { None, TopLeft, TopRight, BottomLeft, BottomRight, Top, Right, Bottom, Left } - public class SelectTool(IMessageBus messageBus) : IDrawingTool +public class SelectTool(IMessageBus messageBus) : IDrawingTool +{ + public string Name => "Select"; + public ToolType Type => ToolType.Select; + + private SKPoint lastPoint; + private SelectionState currentState = SelectionState.None; + private ResizeHandle activeHandle = ResizeHandle.None; + private SKRect originalBounds; + private Dictionary originalTransforms = []; + private SKPoint resizeStartPoint; + private readonly IMessageBus messageBus = messageBus; + + public void OnTouchPressed(SKPoint point, ToolContext context) { - public string Name => "Select"; - public ToolType Type => ToolType.Select; - - private SKPoint lastPoint; - private SelectionState currentState = SelectionState.None; - private ResizeHandle activeHandle = ResizeHandle.None; - private SKRect originalBounds; - private Dictionary originalTransforms = []; - private SKPoint resizeStartPoint; - private readonly IMessageBus messageBus = messageBus; - - public void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + if (context.CurrentLayer?.IsLocked == true) return; - lastPoint = point; + lastPoint = point; - // Check for resize handles if we have a selection - if (context.SelectionObserver.Selected.Any()) - { - var bounds = context.SelectionObserver.GetBounds(); - var handle = GetResizeHandle(point, bounds, context.Scale); + // Check for resize handles if we have a selection + if (context.SelectionObserver.Selected.Any()) + { + var bounds = context.SelectionObserver.GetBounds(); + var handle = GetResizeHandle(point, bounds, context.Scale); - if (handle != ResizeHandle.None) - { - currentState = SelectionState.Resizing; - activeHandle = handle; - resizeStartPoint = point; - originalBounds = bounds; - originalTransforms = context.SelectionObserver.GetAll() - .ToDictionary(e => e, e => e.TransformMatrix); - - messageBus.SendMessage(new CanvasInvalidateMessage()); - return; - } + if (handle != ResizeHandle.None) + { + currentState = SelectionState.Resizing; + activeHandle = handle; + resizeStartPoint = point; + originalBounds = bounds; + originalTransforms = context.SelectionObserver.GetAll() + .ToDictionary(e => e, e => e.TransformMatrix); + + messageBus.SendMessage(new CanvasInvalidateMessage()); + return; } + } - IDrawableElement? hitElement = null; + IDrawableElement? hitElement = null; - if (context.Layers != null) - { - // Iterate layers from Top (Last) to Bottom (First) - foreach (var layer in context.Layers.Reverse()) - { - if (!layer.IsVisible) continue; - - // Hit test elements in this layer, sorted by ZIndex Descending (Topmost first) - var hit = layer.Elements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); - - if (hit != null) - { - hitElement = hit; - break; // Found the top-most element - } - } - } - else + if (context.Layers != null) + { + // Iterate layers from Top (Last) to Bottom (First) + foreach (var layer in context.Layers.Reverse()) { - // Fallback to old behavior if Layers not provided (shouldn't happen with updated context) - hitElement = context.AllElements - .Where(e => e.IsVisible) - .OrderByDescending(e => e.ZIndex) - .FirstOrDefault(e => e.HitTest(point)); - } + if (!layer.IsVisible) continue; - if (hitElement != null) - { - if (!context.SelectionObserver.Contains(hitElement)) + // Hit test elements in this layer, sorted by ZIndex Descending (Topmost first) + var hit = layer.Elements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); + + if (hit != null) { - context.SelectionObserver.Clear(); - context.SelectionObserver.Add(hitElement); + hitElement = hit; + break; // Found the top-most element } - currentState = SelectionState.Dragging; - } - else - { - context.SelectionObserver.Clear(); - currentState = SelectionState.None; } - - messageBus.SendMessage(new CanvasInvalidateMessage()); } - - public void OnTouchMoved(SKPoint point, ToolContext context) + else { - if (context.CurrentLayer?.IsLocked == true) return; - - switch (currentState) - { - case SelectionState.Dragging: - var delta = point - lastPoint; - foreach (var element in context.SelectionObserver.GetAll()) - { - element.Translate(delta); - } - lastPoint = point; - messageBus.SendMessage(new CanvasInvalidateMessage()); - break; - - case SelectionState.Resizing: - PerformResize(point, context); - messageBus.SendMessage(new CanvasInvalidateMessage()); - break; - } + // Fallback to old behavior if Layers not provided (shouldn't happen with updated context) + hitElement = context.AllElements + .Where(e => e.IsVisible) + .OrderByDescending(e => e.ZIndex) + .FirstOrDefault(e => e.HitTest(point)); } - public void OnTouchReleased(SKPoint point, ToolContext context) + if (hitElement != null) { - if (currentState == SelectionState.Dragging || currentState == SelectionState.Resizing) + if (!context.SelectionObserver.Contains(hitElement)) { - messageBus.SendMessage(new DrawingStateChangedMessage()); + context.SelectionObserver.Clear(); + context.SelectionObserver.Add(hitElement); } - - currentState = SelectionState.None; - activeHandle = ResizeHandle.None; - originalTransforms?.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); + currentState = SelectionState.Dragging; } - - public void OnTouchCancelled(ToolContext context) + else { + context.SelectionObserver.Clear(); currentState = SelectionState.None; - activeHandle = ResizeHandle.None; - originalTransforms?.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); } - public void DrawPreview(SKCanvas canvas, ToolContext context) - { - if (context.SelectionObserver.Selected.Any()) - { - var bounds = context.SelectionObserver.GetBounds(); - if (bounds.IsEmpty) return; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - // Draw selection rectangle - using var paint = new SKPaint - { - Style = SKPaintStyle.Stroke, - Color = SKColors.DodgerBlue, - StrokeWidth = 1, - PathEffect = SKPathEffect.CreateDash(new[] { 4f, 4f }, 0) - }; - canvas.DrawRect(bounds, paint); - - // Draw resize handles - if (currentState != SelectionState.Resizing) + public void OnTouchMoved(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; + + switch (currentState) + { + case SelectionState.Dragging: + var delta = point - lastPoint; + foreach (var element in context.SelectionObserver.GetAll()) { - float scale = context.Scale; - // Note: context.Scale is just TotalMatrix.ScaleX in MainViewModel logic. - // But here we need inverse scale for drawing constant size handles? - // GetResizeHandle used (1/ScaleX). - // If context.Scale is ScaleX, then handleDrawScale = 1.0f / context.Scale. - - float handleDrawScale = 1.0f / (Math.Abs(context.Scale) < 0.0001f ? 1.0f : context.Scale); - - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Top), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Top), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Bottom), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Bottom), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Top), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.MidY), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Bottom), handleDrawScale); - SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.MidY), handleDrawScale); + element.Translate(delta); } - } + lastPoint = point; + messageBus.SendMessage(new CanvasInvalidateMessage()); + break; + + case SelectionState.Resizing: + PerformResize(point, context); + messageBus.SendMessage(new CanvasInvalidateMessage()); + break; } + } - private ResizeHandle GetResizeHandle(SKPoint point, SKRect bounds, float scale) + public void OnTouchReleased(SKPoint point, ToolContext context) + { + if (currentState == SelectionState.Dragging || currentState == SelectionState.Resizing) { - const float baseHandleSize = 24f; // Size in screen pixels at 1:1 scale - float scaledHandleSize = baseHandleSize / scale; // Adjust based on current zoom level - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Top), scaledHandleSize)) return ResizeHandle.TopLeft; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Top), scaledHandleSize)) return ResizeHandle.TopRight; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomLeft; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomRight; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Top), scaledHandleSize)) return ResizeHandle.Top; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.MidY), scaledHandleSize)) return ResizeHandle.Right; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Bottom), scaledHandleSize)) return ResizeHandle.Bottom; - if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.MidY), scaledHandleSize)) return ResizeHandle.Left; - return ResizeHandle.None; + messageBus.SendMessage(new DrawingStateChangedMessage()); } - private static bool IsPointNear(SKPoint p1, SKPoint p2, float tolerance) + currentState = SelectionState.None; + activeHandle = ResizeHandle.None; + originalTransforms?.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void OnTouchCancelled(ToolContext context) + { + currentState = SelectionState.None; + activeHandle = ResizeHandle.None; + originalTransforms?.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + public void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (context.SelectionObserver.Selected.Any()) { - return (p1.X - p2.X) * (p1.X - p2.X) + (p1.Y - p2.Y) * (p1.Y - p2.Y) < tolerance * tolerance; + var bounds = context.SelectionObserver.GetBounds(); + if (bounds.IsEmpty) return; + + // Draw selection rectangle + using var paint = new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = SKColors.DodgerBlue, + StrokeWidth = 1, + PathEffect = SKPathEffect.CreateDash(new[] { 4f, 4f }, 0) + }; + canvas.DrawRect(bounds, paint); + + // Draw resize handles + if (currentState != SelectionState.Resizing) + { + float scale = context.Scale; + // Note: context.Scale is just TotalMatrix.ScaleX in MainViewModel logic. + // But here we need inverse scale for drawing constant size handles? + // GetResizeHandle used (1/ScaleX). + // If context.Scale is ScaleX, then handleDrawScale = 1.0f / context.Scale. + + float handleDrawScale = 1.0f / (Math.Abs(context.Scale) < 0.0001f ? 1.0f : context.Scale); + + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Top), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Top), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Bottom), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Bottom), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Top), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.MidY), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.MidX, bounds.Bottom), handleDrawScale); + SelectTool.DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.MidY), handleDrawScale); + } } + } - private void PerformResize(SKPoint currentPoint, ToolContext context) - { - if (originalTransforms == null) return; + private ResizeHandle GetResizeHandle(SKPoint point, SKRect bounds, float scale) + { + const float baseHandleSize = 24f; // Size in screen pixels at 1:1 scale + float scaledHandleSize = baseHandleSize / scale; // Adjust based on current zoom level + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Top), scaledHandleSize)) return ResizeHandle.TopLeft; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Top), scaledHandleSize)) return ResizeHandle.TopRight; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomLeft; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.Bottom), scaledHandleSize)) return ResizeHandle.BottomRight; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Top), scaledHandleSize)) return ResizeHandle.Top; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Right, bounds.MidY), scaledHandleSize)) return ResizeHandle.Right; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.MidX, bounds.Bottom), scaledHandleSize)) return ResizeHandle.Bottom; + if (SelectTool.IsPointNear(point, new SKPoint(bounds.Left, bounds.MidY), scaledHandleSize)) return ResizeHandle.Left; + return ResizeHandle.None; + } - var dragDelta = currentPoint - resizeStartPoint; - var newBounds = SelectTool.CalculateNewBounds(originalBounds, activeHandle, dragDelta); + private static bool IsPointNear(SKPoint p1, SKPoint p2, float tolerance) + { + return (p1.X - p2.X) * (p1.X - p2.X) + (p1.Y - p2.Y) * (p1.Y - p2.Y) < tolerance * tolerance; + } - if (newBounds.Width < 5 || newBounds.Height < 5) return; + private void PerformResize(SKPoint currentPoint, ToolContext context) + { + if (originalTransforms == null) return; - var sx = originalBounds.Width == 0 ? 1 : newBounds.Width / originalBounds.Width; - var sy = originalBounds.Height == 0 ? 1 : newBounds.Height / originalBounds.Height; - var tx = newBounds.Left - (originalBounds.Left * sx); - var ty = newBounds.Top - (originalBounds.Top * sy); + var dragDelta = currentPoint - resizeStartPoint; + var newBounds = SelectTool.CalculateNewBounds(originalBounds, activeHandle, dragDelta); - var transformFromOriginal = new SKMatrix(sx, 0, tx, 0, sy, ty, 0, 0, 1); + if (newBounds.Width < 5 || newBounds.Height < 5) return; - foreach (var element in context.SelectionObserver.GetAll()) - { - if (originalTransforms.TryGetValue(element, out var originalMatrix)) - { - // Apply the resize transformation (calculated in world space) AFTER the original matrix - // New = Resize * Original - element.TransformMatrix = SKMatrix.Concat(transformFromOriginal, originalMatrix); - } - } - } + var sx = originalBounds.Width == 0 ? 1 : newBounds.Width / originalBounds.Width; + var sy = originalBounds.Height == 0 ? 1 : newBounds.Height / originalBounds.Height; + var tx = newBounds.Left - (originalBounds.Left * sx); + var ty = newBounds.Top - (originalBounds.Top * sy); - private static SKRect CalculateNewBounds(SKRect bounds, ResizeHandle handle, SKPoint dragDelta) - { - float left = bounds.Left, top = bounds.Top, right = bounds.Right, bottom = bounds.Bottom; + var transformFromOriginal = new SKMatrix(sx, 0, tx, 0, sy, ty, 0, 0, 1); - switch (handle) + foreach (var element in context.SelectionObserver.GetAll()) + { + if (originalTransforms.TryGetValue(element, out var originalMatrix)) { - case ResizeHandle.TopLeft: left += dragDelta.X; top += dragDelta.Y; break; - case ResizeHandle.Top: top += dragDelta.Y; break; - case ResizeHandle.TopRight: right += dragDelta.X; top += dragDelta.Y; break; - case ResizeHandle.Left: left += dragDelta.X; break; - case ResizeHandle.Right: right += dragDelta.X; break; - case ResizeHandle.BottomLeft: left += dragDelta.X; bottom += dragDelta.Y; break; - case ResizeHandle.Bottom: bottom += dragDelta.Y; break; - case ResizeHandle.BottomRight: right += dragDelta.X; bottom += dragDelta.Y; break; + // Apply the resize transformation (calculated in world space) AFTER the original matrix + // New = Resize * Original + element.TransformMatrix = SKMatrix.Concat(transformFromOriginal, originalMatrix); } + } + } - if (left > right) { var temp = left; left = right; right = temp; } - if (top > bottom) { var temp = top; top = bottom; bottom = temp; } + private static SKRect CalculateNewBounds(SKRect bounds, ResizeHandle handle, SKPoint dragDelta) + { + float left = bounds.Left, top = bounds.Top, right = bounds.Right, bottom = bounds.Bottom; - return new SKRect(left, top, right, bottom); + switch (handle) + { + case ResizeHandle.TopLeft: left += dragDelta.X; top += dragDelta.Y; break; + case ResizeHandle.Top: top += dragDelta.Y; break; + case ResizeHandle.TopRight: right += dragDelta.X; top += dragDelta.Y; break; + case ResizeHandle.Left: left += dragDelta.X; break; + case ResizeHandle.Right: right += dragDelta.X; break; + case ResizeHandle.BottomLeft: left += dragDelta.X; bottom += dragDelta.Y; break; + case ResizeHandle.Bottom: bottom += dragDelta.Y; break; + case ResizeHandle.BottomRight: right += dragDelta.X; bottom += dragDelta.Y; break; } - private static void DrawResizeHandle(SKCanvas canvas, SKPoint point, float scale) + if (left > right) { var temp = left; left = right; right = temp; } + if (top > bottom) { var temp = top; top = bottom; bottom = temp; } + + return new SKRect(left, top, right, bottom); + } + + private static void DrawResizeHandle(SKCanvas canvas, SKPoint point, float scale) + { + const float baseHandleSize = 4f; + float handleSize = baseHandleSize * scale; + using var paint = new SKPaint { - const float baseHandleSize = 4f; - float handleSize = baseHandleSize * scale; - using var paint = new SKPaint - { - Style = SKPaintStyle.Fill, - Color = SKColors.White - }; - canvas.DrawCircle(point, handleSize, paint); - paint.Style = SKPaintStyle.Stroke; - paint.Color = SKColors.DodgerBlue; - paint.StrokeWidth = 1; - canvas.DrawCircle(point, handleSize, paint); - } + Style = SKPaintStyle.Fill, + Color = SKColors.White + }; + canvas.DrawCircle(point, handleSize, paint); + paint.Style = SKPaintStyle.Stroke; + paint.Color = SKColors.DodgerBlue; + paint.StrokeWidth = 1; + canvas.DrawCircle(point, handleSize, paint); } } \ No newline at end of file diff --git a/Logic/Tools/ShapeTool.cs b/Logic/Tools/ShapeTool.cs index dc6a927..5762133 100644 --- a/Logic/Tools/ShapeTool.cs +++ b/Logic/Tools/ShapeTool.cs @@ -24,69 +24,67 @@ using LunaDraw.Logic.Extensions; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; -using LunaDraw.Logic.ViewModels; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.Tools +namespace LunaDraw.Logic.Tools; + +public abstract class ShapeTool(IMessageBus messageBus) : IDrawingTool where T : class, IDrawableElement { - public abstract class ShapeTool(IMessageBus messageBus) : IDrawingTool where T : class, IDrawableElement - { - public abstract string Name { get; } - public abstract ToolType Type { get; } + public abstract string Name { get; } + public abstract ToolType Type { get; } - protected readonly IMessageBus MessageBus = messageBus; - protected SKPoint StartPoint; - protected T? CurrentShape; + protected readonly IMessageBus MessageBus = messageBus; + protected SKPoint StartPoint; + protected T? CurrentShape; - protected abstract T CreateShape(ToolContext context); - protected abstract void UpdateShape(T shape, SKRect bounds, SKMatrix transform); - protected abstract bool IsShapeValid(T shape); + protected abstract T CreateShape(ToolContext context); + protected abstract void UpdateShape(T shape, SKRect bounds, SKMatrix transform); + protected abstract bool IsShapeValid(T shape); - public virtual void OnTouchPressed(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true) return; + public virtual void OnTouchPressed(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true) return; - StartPoint = point; - CurrentShape = CreateShape(context); - } + StartPoint = point; + CurrentShape = CreateShape(context); + } - public virtual void OnTouchMoved(SKPoint point, ToolContext context) - { - if (context.CurrentLayer?.IsLocked == true || CurrentShape == null) return; + public virtual void OnTouchMoved(SKPoint point, ToolContext context) + { + if (context.CurrentLayer?.IsLocked == true || CurrentShape == null) return; - var (transform, bounds) = context.CanvasMatrix.CalculateRotatedBounds(StartPoint, point); - UpdateShape(CurrentShape, bounds, transform); + var (transform, bounds) = context.CanvasMatrix.CalculateRotatedBounds(StartPoint, point); + UpdateShape(CurrentShape, bounds, transform); - MessageBus.SendMessage(new CanvasInvalidateMessage()); - } + MessageBus.SendMessage(new CanvasInvalidateMessage()); + } - public virtual void OnTouchReleased(SKPoint point, ToolContext context) - { - if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || CurrentShape == null) return; + public virtual void OnTouchReleased(SKPoint point, ToolContext context) + { + if (context.CurrentLayer == null || context.CurrentLayer.IsLocked || CurrentShape == null) return; - if (IsShapeValid(CurrentShape)) - { - context.CurrentLayer.Elements.Add(CurrentShape); - MessageBus.SendMessage(new DrawingStateChangedMessage()); - } + if (IsShapeValid(CurrentShape)) + { + context.CurrentLayer.Elements.Add(CurrentShape); + MessageBus.SendMessage(new DrawingStateChangedMessage()); + } - CurrentShape = null; - MessageBus.SendMessage(new CanvasInvalidateMessage()); - } + CurrentShape = null; + MessageBus.SendMessage(new CanvasInvalidateMessage()); + } - public virtual void OnTouchCancelled(ToolContext context) - { - CurrentShape = null; - MessageBus.SendMessage(new CanvasInvalidateMessage()); - } + public virtual void OnTouchCancelled(ToolContext context) + { + CurrentShape = null; + MessageBus.SendMessage(new CanvasInvalidateMessage()); + } - public virtual void DrawPreview(SKCanvas canvas, ToolContext context) - { - if (CurrentShape != null) - { - CurrentShape.Draw(canvas); - } - } + public virtual void DrawPreview(SKCanvas canvas, ToolContext context) + { + if (CurrentShape != null) + { + CurrentShape.Draw(canvas); } + } } diff --git a/Logic/Utils/BitmapCache.cs b/Logic/Utils/BitmapCache.cs index 360a0e4..f0be4c9 100644 --- a/Logic/Utils/BitmapCache.cs +++ b/Logic/Utils/BitmapCache.cs @@ -24,149 +24,148 @@ using SkiaSharp; using System.Collections.Concurrent; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public interface IBitmapCache : IDisposable +{ + SKBitmap GetBitmap(string path, int targetWidth, int targetHeight); + Task GetBitmapAsync(string path, int targetWidth, int targetHeight); + void ClearCache(); +} + +public class BitmapCache : IBitmapCache { - public interface IBitmapCache : IDisposable + private readonly ConcurrentDictionary> cache = new(); + + public SKBitmap GetBitmap(string path, int targetWidth, int targetHeight) + { + var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); + + if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) + { + return bitmap; + } + + var newBitmap = BitmapCache.LoadDownsampledBitmap(path, targetWidth, targetHeight); + if (newBitmap != null) + { + cache.AddOrUpdate(key, + new WeakReference(newBitmap), + (_, __) => new WeakReference(newBitmap)); + } + + return newBitmap ?? new SKBitmap(); + } + + public async Task GetBitmapAsync(string path, int targetWidth, int targetHeight) + { + var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); + + if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) { - SKBitmap GetBitmap(string path, int targetWidth, int targetHeight); - Task GetBitmapAsync(string path, int targetWidth, int targetHeight); - void ClearCache(); + return bitmap; } - public class BitmapCache : IBitmapCache + return await Task.Run(() => { - private readonly ConcurrentDictionary> cache = new(); - - public SKBitmap GetBitmap(string path, int targetWidth, int targetHeight) - { - var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); - - if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) - { - return bitmap; - } - - var newBitmap = BitmapCache.LoadDownsampledBitmap(path, targetWidth, targetHeight); - if (newBitmap != null) - { - cache.AddOrUpdate(key, - new WeakReference(newBitmap), - (_, __) => new WeakReference(newBitmap)); - } - - return newBitmap ?? new SKBitmap(); - } - - public async Task GetBitmapAsync(string path, int targetWidth, int targetHeight) - { - var key = BitmapCache.GenerateKey(path, targetWidth, targetHeight); - - if (cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var bitmap)) - { - return bitmap; - } - - return await Task.Run(() => - { - var newBitmap = BitmapCache.LoadDownsampledBitmap(path, targetWidth, targetHeight); - if (newBitmap != null) - { - cache.AddOrUpdate(key, - new WeakReference(newBitmap), - (_, __) => new WeakReference(newBitmap)); - } - - return newBitmap ?? new SKBitmap(); - }); - } - - private static string GenerateKey(string path, int width, int height) - { - return $"{path}_{width}x{height}"; - } - - private static SKBitmap LoadDownsampledBitmap(string path, int targetWidth, int targetHeight) - { - try - { - if (!File.Exists(path)) - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] File not found: {path}"); - - return new SKBitmap(); - } - - using var stream = File.OpenRead(path); - using var codec = SKCodec.Create(stream); - - if (codec == null) - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] Failed to create codec for: {path}"); - - return new SKBitmap(); - } - - var info = codec.Info; - - // Calculate scale - float scale = 1.0f; - if (targetWidth > 0 && targetHeight > 0) - { - float scaleX = (float)targetWidth / info.Width; - float scaleY = (float)targetHeight / info.Height; - scale = Math.Min(scaleX, scaleY); - } - - if (scale >= 1.0f || (targetWidth == 0 && targetHeight == 0)) - { - return SKBitmap.Decode(codec); - } - - // Get supported dimensions for this scale - var supportedInfo = codec.GetScaledDimensions(scale); - - // Use the supported dimensions for decoding - var decodeInfo = new SKImageInfo(supportedInfo.Width, supportedInfo.Height, info.ColorType, info.AlphaType); - - var bitmap = new SKBitmap(decodeInfo); - var result = codec.GetPixels(decodeInfo, bitmap.GetPixels()); - - if (result == SKCodecResult.Success || result == SKCodecResult.IncompleteInput) - { - return bitmap; - } - else - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] GetPixels failed: {result}"); - bitmap.Dispose(); - // Fallback: try full decode if downsample fails? - // Or maybe the scale was just invalid. - return new SKBitmap(); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"[BitmapCache] Exception loading bitmap: {ex}"); - - return new SKBitmap(); - } - } - - public void ClearCache() - { - foreach (var weakRef in cache.Values) - { - if (weakRef.TryGetTarget(out var bitmap)) - { - bitmap?.Dispose(); - } - } - cache.Clear(); - } - - public void Dispose() - { - ClearCache(); - } + var newBitmap = BitmapCache.LoadDownsampledBitmap(path, targetWidth, targetHeight); + if (newBitmap != null) + { + cache.AddOrUpdate(key, + new WeakReference(newBitmap), + (_, __) => new WeakReference(newBitmap)); + } + + return newBitmap ?? new SKBitmap(); + }); + } + + private static string GenerateKey(string path, int width, int height) + { + return $"{path}_{width}x{height}"; + } + + private static SKBitmap LoadDownsampledBitmap(string path, int targetWidth, int targetHeight) + { + try + { + if (!File.Exists(path)) + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] File not found: {path}"); + + return new SKBitmap(); + } + + using var stream = File.OpenRead(path); + using var codec = SKCodec.Create(stream); + + if (codec == null) + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] Failed to create codec for: {path}"); + + return new SKBitmap(); + } + + var info = codec.Info; + + // Calculate scale + float scale = 1.0f; + if (targetWidth > 0 && targetHeight > 0) + { + float scaleX = (float)targetWidth / info.Width; + float scaleY = (float)targetHeight / info.Height; + scale = Math.Min(scaleX, scaleY); + } + + if (scale >= 1.0f || (targetWidth == 0 && targetHeight == 0)) + { + return SKBitmap.Decode(codec); + } + + // Get supported dimensions for this scale + var supportedInfo = codec.GetScaledDimensions(scale); + + // Use the supported dimensions for decoding + var decodeInfo = new SKImageInfo(supportedInfo.Width, supportedInfo.Height, info.ColorType, info.AlphaType); + + var bitmap = new SKBitmap(decodeInfo); + var result = codec.GetPixels(decodeInfo, bitmap.GetPixels()); + + if (result == SKCodecResult.Success || result == SKCodecResult.IncompleteInput) + { + return bitmap; + } + else + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] GetPixels failed: {result}"); + bitmap.Dispose(); + // Fallback: try full decode if downsample fails? + // Or maybe the scale was just invalid. + return new SKBitmap(); + } } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[BitmapCache] Exception loading bitmap: {ex}"); + + return new SKBitmap(); + } + } + + public void ClearCache() + { + foreach (var weakRef in cache.Values) + { + if (weakRef.TryGetTarget(out var bitmap)) + { + bitmap?.Dispose(); + } + } + cache.Clear(); + } + + public void Dispose() + { + ClearCache(); + } } diff --git a/Logic/Utils/ClipboardMemento.cs b/Logic/Utils/ClipboardMemento.cs index ce231f4..e18e1c7 100644 --- a/Logic/Utils/ClipboardMemento.cs +++ b/Logic/Utils/ClipboardMemento.cs @@ -24,23 +24,22 @@ using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public class ClipboardMemento : ReactiveObject { - public class ClipboardMemento : ReactiveObject - { - private List clipboard = new(); + private List clipboard = new(); - public void Copy(IEnumerable elements) - { - clipboard = elements.Select(e => e.Clone()).ToList(); - this.RaisePropertyChanged(nameof(HasItems)); - } + public void Copy(IEnumerable elements) + { + clipboard = elements.Select(e => e.Clone()).ToList(); + this.RaisePropertyChanged(nameof(HasItems)); + } - public IEnumerable Paste() - { - return clipboard.Select(e => e.Clone()); - } + public IEnumerable Paste() + { + return clipboard.Select(e => e.Clone()); + } - public bool HasItems => clipboard.Count > 0; - } + public bool HasItems => clipboard.Count > 0; } diff --git a/Logic/Utils/HistoryMemento.cs b/Logic/Utils/HistoryMemento.cs index c47e2dc..812ffed 100644 --- a/Logic/Utils/HistoryMemento.cs +++ b/Logic/Utils/HistoryMemento.cs @@ -24,64 +24,63 @@ using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public class HistoryMemento : ReactiveObject { - public class HistoryMemento : ReactiveObject - { - private readonly List> history = []; - private int historyIndex = -1; + private readonly List> history = []; + private int historyIndex = -1; - public bool CanUndo => historyIndex > 0; - public bool CanRedo => historyIndex < history.Count - 1; + public bool CanUndo => historyIndex > 0; + public bool CanRedo => historyIndex < history.Count - 1; - public void SaveState(IEnumerable layers) + public void SaveState(IEnumerable layers) + { + // If we have undone, and then make a new action, we clear the 'redo' history + if (historyIndex < history.Count - 1) { - // If we have undone, and then make a new action, we clear the 'redo' history - if (historyIndex < history.Count - 1) - { - history.RemoveRange(historyIndex + 1, history.Count - (historyIndex + 1)); - } + history.RemoveRange(historyIndex + 1, history.Count - (historyIndex + 1)); + } - // Deep copy the layers - var stateSnapshot = layers.Select(l => l.Clone()).ToList(); - history.Add(stateSnapshot); - historyIndex++; + // Deep copy the layers + var stateSnapshot = layers.Select(l => l.Clone()).ToList(); + history.Add(stateSnapshot); + historyIndex++; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); - } + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); + } - public List? Undo() - { - if (!CanUndo) return null; - historyIndex--; + public List? Undo() + { + if (!CanUndo) return null; + historyIndex--; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); - // Return a deep copy of the state to ensure history integrity - return history[historyIndex].Select(l => l.Clone()).ToList(); - } + // Return a deep copy of the state to ensure history integrity + return history[historyIndex].Select(l => l.Clone()).ToList(); + } - public List? Redo() - { - if (!CanRedo) return null; - historyIndex++; + public List? Redo() + { + if (!CanRedo) return null; + historyIndex++; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); - // Return a deep copy of the state - return history[historyIndex].Select(l => l.Clone()).ToList(); - } + // Return a deep copy of the state + return history[historyIndex].Select(l => l.Clone()).ToList(); + } - public void Clear() - { - history.Clear(); - historyIndex = -1; + public void Clear() + { + history.Clear(); + historyIndex = -1; - this.RaisePropertyChanged(nameof(CanUndo)); - this.RaisePropertyChanged(nameof(CanRedo)); - } + this.RaisePropertyChanged(nameof(CanUndo)); + this.RaisePropertyChanged(nameof(CanRedo)); } } diff --git a/Logic/Utils/ILayerFacade.cs b/Logic/Utils/ILayerFacade.cs index fad5e07..e24937f 100644 --- a/Logic/Utils/ILayerFacade.cs +++ b/Logic/Utils/ILayerFacade.cs @@ -24,19 +24,18 @@ using System.Collections.ObjectModel; using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public interface ILayerFacade { - public interface ILayerFacade - { - ObservableCollection Layers { get; } - Layer? CurrentLayer { get; set; } - HistoryMemento HistoryMemento { get; } - void AddLayer(); - void RemoveLayer(Layer layer); - void MoveLayerForward(Layer layer); - void MoveLayerBackward(Layer layer); - void MoveLayer(int oldIndex, int newIndex); - void MoveElementsToLayer(IEnumerable elements, Layer targetLayer); - void SaveState(); - } + ObservableCollection Layers { get; } + Layer? CurrentLayer { get; set; } + HistoryMemento HistoryMemento { get; } + void AddLayer(); + void RemoveLayer(Layer layer); + void MoveLayerForward(Layer layer); + void MoveLayerBackward(Layer layer); + void MoveLayer(int oldIndex, int newIndex); + void MoveElementsToLayer(IEnumerable elements, Layer targetLayer); + void SaveState(); } diff --git a/Logic/Utils/LayerFacade.cs b/Logic/Utils/LayerFacade.cs index 0109d24..5a29337 100644 --- a/Logic/Utils/LayerFacade.cs +++ b/Logic/Utils/LayerFacade.cs @@ -26,120 +26,119 @@ using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.Managers +namespace LunaDraw.Logic.Utils; + +public class LayerFacade : ReactiveObject, ILayerFacade { - public class LayerFacade : ReactiveObject, ILayerFacade + public ObservableCollection Layers { get; } = []; + public HistoryMemento HistoryMemento { get; } = new HistoryMemento(); + + private Layer? currentLayer; + public Layer? CurrentLayer { - public ObservableCollection Layers { get; } = []; - public HistoryMemento HistoryMemento { get; } = new HistoryMemento(); + get => currentLayer; + set => this.RaiseAndSetIfChanged(ref currentLayer, value); + } - private Layer? currentLayer; - public Layer? CurrentLayer - { - get => currentLayer; - set => this.RaiseAndSetIfChanged(ref currentLayer, value); - } + private readonly IMessageBus messageBus; - private readonly IMessageBus messageBus; + public LayerFacade(IMessageBus messageBus) + { + this.messageBus = messageBus; + // Initialize with a default layer + var initialLayer = new Layer { Name = "Layer 1" }; + Layers.Add(initialLayer); + CurrentLayer = initialLayer; - public LayerFacade(IMessageBus messageBus) - { - this.messageBus = messageBus; - // Initialize with a default layer - var initialLayer = new Layer { Name = "Layer 1" }; - Layers.Add(initialLayer); - CurrentLayer = initialLayer; + this.messageBus.Listen().Subscribe(_ => SaveState()); - this.messageBus.Listen().Subscribe(_ => SaveState()); + SaveState(); + } - SaveState(); - } + public void AddLayer() + { + var newLayer = new Layer { Name = $"Layer {Layers.Count + 1}" }; + Layers.Add(newLayer); + CurrentLayer = newLayer; + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } - public void AddLayer() + public void RemoveLayer(Layer layer) + { + if (Layers.Count > 1) { - var newLayer = new Layer { Name = $"Layer {Layers.Count + 1}" }; - Layers.Add(newLayer); - CurrentLayer = newLayer; + // Select a different layer before removing the current one to avoid UI selection issues + var nextLayer = Layers.FirstOrDefault(l => l != layer); + if (nextLayer != null) + { + CurrentLayer = nextLayer; + } + + Layers.Remove(layer); SaveState(); messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void RemoveLayer(Layer layer) + public void MoveLayerForward(Layer layer) + { + int index = Layers.IndexOf(layer); + if (index >= 0 && index < Layers.Count - 1) { - if (Layers.Count > 1) - { - // Select a different layer before removing the current one to avoid UI selection issues - var nextLayer = Layers.FirstOrDefault(l => l != layer); - if (nextLayer != null) - { - CurrentLayer = nextLayer; - } - - Layers.Remove(layer); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + Layers.Move(index, index + 1); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void MoveLayerForward(Layer layer) + public void MoveLayerBackward(Layer layer) + { + int index = Layers.IndexOf(layer); + if (index > 0) { - int index = Layers.IndexOf(layer); - if (index >= 0 && index < Layers.Count - 1) - { - Layers.Move(index, index + 1); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + Layers.Move(index, index - 1); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void MoveLayerBackward(Layer layer) + public void MoveLayer(int oldIndex, int newIndex) + { + if (oldIndex >= 0 && oldIndex < Layers.Count && newIndex >= 0 && newIndex < Layers.Count) { - int index = Layers.IndexOf(layer); - if (index > 0) - { - Layers.Move(index, index - 1); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } + Layers.Move(oldIndex, newIndex); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } - public void MoveLayer(int oldIndex, int newIndex) - { - if (oldIndex >= 0 && oldIndex < Layers.Count && newIndex >= 0 && newIndex < Layers.Count) - { - Layers.Move(oldIndex, newIndex); - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - } + public void MoveElementsToLayer(IEnumerable elements, Layer targetLayer) + { + if (!Layers.Contains(targetLayer)) return; - public void MoveElementsToLayer(IEnumerable elements, Layer targetLayer) + bool changed = false; + foreach (var element in elements.ToList()) // ToList to avoid modification during enumeration { - if (!Layers.Contains(targetLayer)) return; - - bool changed = false; - foreach (var element in elements.ToList()) // ToList to avoid modification during enumeration - { - // Find the layer containing this element - var sourceLayer = Layers.FirstOrDefault(l => l.Elements.Contains(element)); - if (sourceLayer != null && sourceLayer != targetLayer) - { - sourceLayer.Elements.Remove(element); - targetLayer.Elements.Add(element); - changed = true; - } - } - - if (changed) + // Find the layer containing this element + var sourceLayer = Layers.FirstOrDefault(l => l.Elements.Contains(element)); + if (sourceLayer != null && sourceLayer != targetLayer) { - SaveState(); - messageBus.SendMessage(new CanvasInvalidateMessage()); + sourceLayer.Elements.Remove(element); + targetLayer.Elements.Add(element); + changed = true; } } - public void SaveState() + if (changed) { - HistoryMemento.SaveState(Layers); + SaveState(); + messageBus.SendMessage(new CanvasInvalidateMessage()); } } + + public void SaveState() + { + HistoryMemento.SaveState(Layers); + } } \ No newline at end of file diff --git a/Logic/Utils/QuadTree.cs b/Logic/Utils/QuadTree.cs deleted file mode 100644 index 8acb2e5..0000000 --- a/Logic/Utils/QuadTree.cs +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright (c) 2025 CodeSoupCafe LLC - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -using SkiaSharp; - -namespace LunaDraw.Logic.Utils -{ - public class QuadTree(int level, SKRect bounds, Func getBounds) where T : class - { - private readonly int maxObjects = 10; - private readonly int maxLevels = 5; - - private readonly int level = level; - private readonly List objects = []; - private readonly SKRect bounds = bounds; - private readonly Func getBounds = getBounds; - private QuadTree[]? nodes; - - public void Clear() - { - objects.Clear(); - - if (nodes != null) - { - foreach (var node in nodes) - { - node.Clear(); - } - nodes = null; - } - } - - private void Split() - { - float subWidth = bounds.Width / 2f; - float subHeight = bounds.Height / 2f; - float x = bounds.Left; - float y = bounds.Top; - - nodes = new QuadTree[4]; - nodes[0] = new QuadTree(level + 1, new SKRect(x + subWidth, y, x + subWidth + subWidth, y + subHeight), getBounds); - nodes[1] = new QuadTree(level + 1, new SKRect(x, y, x + subWidth, y + subHeight), getBounds); - nodes[2] = new QuadTree(level + 1, new SKRect(x, y + subHeight, x + subWidth, y + subHeight + subHeight), getBounds); - nodes[3] = new QuadTree(level + 1, new SKRect(x + subWidth, y + subHeight, x + subWidth + subWidth, y + subHeight + subHeight), getBounds); - } - - /* - * Index of the quadrant the object belongs to - */ - private int GetIndex(SKRect pRect) - { - int index = -1; - double verticalMidpoint = bounds.Left + (bounds.Width / 2f); - double horizontalMidpoint = bounds.Top + (bounds.Height / 2f); - - bool topQuadrant = pRect.Top < horizontalMidpoint && pRect.Bottom < horizontalMidpoint; - bool bottomQuadrant = pRect.Top > horizontalMidpoint; - - if (pRect.Left < verticalMidpoint && pRect.Right < verticalMidpoint) - { - if (topQuadrant) - { - index = 1; - } - else if (bottomQuadrant) - { - index = 2; - } - } - else if (pRect.Left > verticalMidpoint) - { - if (topQuadrant) - { - index = 0; - } - else if (bottomQuadrant) - { - index = 3; - } - } - - return index; - } - - public void Insert(T pObject) - { - if (nodes != null) - { - int index = GetIndex(getBounds(pObject)); - - if (index != -1) - { - nodes[index].Insert(pObject); - return; - } - } - - objects.Add(pObject); - - if (objects.Count > maxObjects && level < maxLevels) - { - if (nodes == null) - { - Split(); - } - - int i = 0; - while (i < objects.Count) - { - int index = GetIndex(getBounds(objects[i])); - if (index != -1) - { - nodes![index].Insert(objects[i]); - objects.RemoveAt(i); - } - else - { - i++; - } - } - } - } - - public List Retrieve(List returnObjects, SKRect pRect) - { - int index = GetIndex(pRect); - if (index != -1 && nodes != null) - { - nodes[index].Retrieve(returnObjects, pRect); - } - else if (nodes != null) - { - // If the rect doesn't fit into a specific quadrant (overlaps multiple), - // we must query all quadrants that it touches. - // Simplified: query all subnodes if we can't determine a single one. - // Or strictly check intersection. - // For now, naive approach: if it doesn't fit one, retrieve from all. - foreach (var node in nodes) - { - // Optimization: check intersection with node bounds - if (node.bounds.IntersectsWith(pRect)) - { - node.Retrieve(returnObjects, pRect); - } - } - } - - returnObjects.AddRange(objects); - - return returnObjects; - } - } -} \ No newline at end of file diff --git a/Logic/Utils/QuadTreeMemento.cs b/Logic/Utils/QuadTreeMemento.cs new file mode 100644 index 0000000..3f23d4a --- /dev/null +++ b/Logic/Utils/QuadTreeMemento.cs @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 CodeSoupCafe LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +using SkiaSharp; + +namespace LunaDraw.Logic.Utils; + +public class QuadTreeMemento(int level, SKRect bounds, Func getBounds) where T : class +{ + private readonly int maxObjects = 10; + private readonly int maxLevels = 5; + + private readonly int level = level; + private readonly List objects = []; + private readonly SKRect bounds = bounds; + private readonly Func getBounds = getBounds; + private QuadTreeMemento[]? nodes; + + public void Clear() + { + objects.Clear(); + + if (nodes != null) + { + foreach (var node in nodes) + { + node.Clear(); + } + nodes = null; + } + } + + private void Split() + { + float subWidth = bounds.Width / 2f; + float subHeight = bounds.Height / 2f; + float x = bounds.Left; + float y = bounds.Top; + + nodes = new QuadTreeMemento[4]; + nodes[0] = new QuadTreeMemento(level + 1, new SKRect(x + subWidth, y, x + subWidth + subWidth, y + subHeight), getBounds); + nodes[1] = new QuadTreeMemento(level + 1, new SKRect(x, y, x + subWidth, y + subHeight), getBounds); + nodes[2] = new QuadTreeMemento(level + 1, new SKRect(x, y + subHeight, x + subWidth, y + subHeight + subHeight), getBounds); + nodes[3] = new QuadTreeMemento(level + 1, new SKRect(x + subWidth, y + subHeight, x + subWidth + subWidth, y + subHeight + subHeight), getBounds); + } + + /* + * Index of the quadrant the object belongs to + */ + private int GetIndex(SKRect pRect) + { + int index = -1; + double verticalMidpoint = bounds.Left + (bounds.Width / 2f); + double horizontalMidpoint = bounds.Top + (bounds.Height / 2f); + + bool topQuadrant = pRect.Top < horizontalMidpoint && pRect.Bottom < horizontalMidpoint; + bool bottomQuadrant = pRect.Top > horizontalMidpoint; + + if (pRect.Left < verticalMidpoint && pRect.Right < verticalMidpoint) + { + if (topQuadrant) + { + index = 1; + } + else if (bottomQuadrant) + { + index = 2; + } + } + else if (pRect.Left > verticalMidpoint) + { + if (topQuadrant) + { + index = 0; + } + else if (bottomQuadrant) + { + index = 3; + } + } + + return index; + } + + public void Insert(T pObject) + { + if (nodes != null) + { + int index = GetIndex(getBounds(pObject)); + + if (index != -1) + { + nodes[index].Insert(pObject); + return; + } + } + + objects.Add(pObject); + + if (objects.Count > maxObjects && level < maxLevels) + { + if (nodes == null) + { + Split(); + } + + int i = 0; + while (i < objects.Count) + { + int index = GetIndex(getBounds(objects[i])); + if (index != -1) + { + nodes![index].Insert(objects[i]); + objects.RemoveAt(i); + } + else + { + i++; + } + } + } + } + + public List Retrieve(List returnObjects, SKRect pRect) + { + int index = GetIndex(pRect); + if (index != -1 && nodes != null) + { + nodes[index].Retrieve(returnObjects, pRect); + } + else if (nodes != null) + { + // If the rect doesn't fit into a specific quadrant (overlaps multiple), + // we must query all quadrants that it touches. + // Simplified: query all subnodes if we can't determine a single one. + // Or strictly check intersection. + // For now, naive approach: if it doesn't fit one, retrieve from all. + foreach (var node in nodes) + { + // Optimization: check intersection with node bounds + if (node.bounds.IntersectsWith(pRect)) + { + node.Retrieve(returnObjects, pRect); + } + } + } + + returnObjects.AddRange(objects); + + return returnObjects; + } +} \ No newline at end of file diff --git a/Logic/Utils/SelectionObserver.cs b/Logic/Utils/SelectionObserver.cs index fdccb7f..6251748 100644 --- a/Logic/Utils/SelectionObserver.cs +++ b/Logic/Utils/SelectionObserver.cs @@ -29,116 +29,115 @@ using SkiaSharp; -namespace LunaDraw.Logic.Managers -{ -public class SelectionObserver : ReactiveObject - { - private readonly ObservableCollection selected = []; - public ReadOnlyObservableCollection Selected { get; } +namespace LunaDraw.Logic.Utils; - public SelectionObserver() - { - Selected = new ReadOnlyObservableCollection(selected); - } +public class SelectionObserver : ReactiveObject +{ + private readonly ObservableCollection selected = []; + public ReadOnlyObservableCollection Selected { get; } - public void Clear() - { - if (selected.Count == 0) return; + public SelectionObserver() + { + Selected = new ReadOnlyObservableCollection(selected); + } - var elementsToClear = selected.ToList(); - selected.Clear(); + public void Clear() + { + if (selected.Count == 0) return; - foreach (var el in elementsToClear) - { - el.IsSelected = false; - } + var elementsToClear = selected.ToList(); + selected.Clear(); - OnSelectionChanged(); - } - - public void Add(IDrawableElement element) + foreach (var el in elementsToClear) { - if (element == null || selected.Contains(element)) return; - element.IsSelected = true; - selected.Add(element); - OnSelectionChanged(); + el.IsSelected = false; } - public void AddRange(IEnumerable elements) + OnSelectionChanged(); + } + + public void Add(IDrawableElement element) + { + if (element == null || selected.Contains(element)) return; + element.IsSelected = true; + selected.Add(element); + OnSelectionChanged(); + } + + public void AddRange(IEnumerable elements) + { + var changed = false; + foreach (var element in elements) { - var changed = false; - foreach (var element in elements) + if (element != null && !selected.Contains(element)) { - if (element != null && !selected.Contains(element)) - { - element.IsSelected = true; - selected.Add(element); - changed = true; - } + element.IsSelected = true; + selected.Add(element); + changed = true; } - - if (changed) - OnSelectionChanged(); } - public void Remove(IDrawableElement element) - { - if (element == null || !selected.Contains(element)) return; - element.IsSelected = false; - selected.Remove(element); + if (changed) OnSelectionChanged(); - } + } - public void Toggle(IDrawableElement element) - { - if (element == null) return; + public void Remove(IDrawableElement element) + { + if (element == null || !selected.Contains(element)) return; + element.IsSelected = false; + selected.Remove(element); + OnSelectionChanged(); + } - if (selected.Contains(element)) - { - Remove(element); - } - else - { - Add(element); - } - } + public void Toggle(IDrawableElement element) + { + if (element == null) return; - public bool Contains(IDrawableElement element) + if (selected.Contains(element)) { - return element != null && selected.Contains(element); + Remove(element); } - - public IReadOnlyList GetAll() + else { - return selected.ToList().AsReadOnly(); + Add(element); } + } - public SKRect GetBounds() - { - if (selected.Count == 0) - { - return SKRect.Empty; - } + public bool Contains(IDrawableElement element) + { + return element != null && selected.Contains(element); + } - var bounds = selected[0].Bounds; - for (var i = 1; i < selected.Count; i++) - { - bounds.Union(selected[i].Bounds); - } + public IReadOnlyList GetAll() + { + return selected.ToList().AsReadOnly(); + } - return bounds; + public SKRect GetBounds() + { + if (selected.Count == 0) + { + return SKRect.Empty; } - private void OnSelectionChanged() + var bounds = selected[0].Bounds; + for (var i = 1; i < selected.Count; i++) { - this.RaisePropertyChanged(nameof(Bounds)); - this.RaisePropertyChanged(nameof(HasSelection)); - SelectionChanged?.Invoke(this, EventArgs.Empty); + bounds.Union(selected[i].Bounds); } - public event EventHandler? SelectionChanged; + return bounds; + } - public SKRect Bounds => GetBounds(); - public bool HasSelection => selected.Count > 0; + private void OnSelectionChanged() + { + this.RaisePropertyChanged(nameof(Bounds)); + this.RaisePropertyChanged(nameof(HasSelection)); + SelectionChanged?.Invoke(this, EventArgs.Empty); } + + public event EventHandler? SelectionChanged; + + public SKRect Bounds => GetBounds(); + public bool HasSelection => selected.Count > 0; } \ No newline at end of file diff --git a/Logic/ViewModels/HistoryViewModel.cs b/Logic/ViewModels/HistoryViewModel.cs index 5534336..9df1988 100644 --- a/Logic/ViewModels/HistoryViewModel.cs +++ b/Logic/ViewModels/HistoryViewModel.cs @@ -22,75 +22,74 @@ */ using System.Reactive; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using ReactiveUI; using LunaDraw.Logic.Models; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class HistoryViewModel : ReactiveObject { - public class HistoryViewModel : ReactiveObject - { - private readonly HistoryMemento historyMemento; - private readonly ILayerFacade layerFacade; - private readonly IMessageBus messageBus; + private readonly HistoryMemento historyMemento; + private readonly ILayerFacade layerFacade; + private readonly IMessageBus messageBus; - public HistoryViewModel(ILayerFacade layerFacade, IMessageBus messageBus) - { - this.layerFacade = layerFacade; - historyMemento = layerFacade.HistoryMemento; - this.messageBus = messageBus; + public HistoryViewModel(ILayerFacade layerFacade, IMessageBus messageBus) + { + this.layerFacade = layerFacade; + historyMemento = layerFacade.HistoryMemento; + this.messageBus = messageBus; - // Observables for CanUndo/CanRedo - var canUndo = this.WhenAnyValue(x => x.historyMemento.CanUndo); - var canRedo = this.WhenAnyValue(x => x.historyMemento.CanRedo); + // Observables for CanUndo/CanRedo + var canUndo = this.WhenAnyValue(x => x.historyMemento.CanUndo); + var canRedo = this.WhenAnyValue(x => x.historyMemento.CanRedo); - UndoCommand = ReactiveCommand.Create(Undo, canUndo, RxApp.MainThreadScheduler); - RedoCommand = ReactiveCommand.Create(Redo, canRedo, RxApp.MainThreadScheduler); + UndoCommand = ReactiveCommand.Create(Undo, canUndo, RxApp.MainThreadScheduler); + RedoCommand = ReactiveCommand.Create(Redo, canRedo, RxApp.MainThreadScheduler); - // Expose properties for binding - canUndoProp = canUndo.ToProperty(this, x => x.CanUndo); - canRedoProp = canRedo.ToProperty(this, x => x.CanRedo); - } + // Expose properties for binding + canUndoProp = canUndo.ToProperty(this, x => x.CanUndo); + canRedoProp = canRedo.ToProperty(this, x => x.CanRedo); + } - private readonly ObservableAsPropertyHelper canUndoProp; - public bool CanUndo => canUndoProp.Value; + private readonly ObservableAsPropertyHelper canUndoProp; + public bool CanUndo => canUndoProp.Value; - private readonly ObservableAsPropertyHelper canRedoProp; - public bool CanRedo => canRedoProp.Value; + private readonly ObservableAsPropertyHelper canRedoProp; + public bool CanRedo => canRedoProp.Value; - public ReactiveCommand UndoCommand { get; } - public ReactiveCommand RedoCommand { get; } + public ReactiveCommand UndoCommand { get; } + public ReactiveCommand RedoCommand { get; } - private void Undo() - { - var state = historyMemento.Undo(); - if (state != null) - { - RestoreState(state); - } - } + private void Undo() + { + var state = historyMemento.Undo(); + if (state != null) + { + RestoreState(state); + } + } - private void Redo() - { - var state = historyMemento.Redo(); - if (state != null) - { - RestoreState(state); - } - } + private void Redo() + { + var state = historyMemento.Redo(); + if (state != null) + { + RestoreState(state); + } + } - private void RestoreState(List state) - { - layerFacade.Layers.Clear(); - foreach (var layer in state) - { - layerFacade.Layers.Add(layer); - } + private void RestoreState(List state) + { + layerFacade.Layers.Clear(); + foreach (var layer in state) + { + layerFacade.Layers.Add(layer); + } - var currentLayerId = layerFacade.CurrentLayer?.Id; - layerFacade.CurrentLayer = layerFacade.Layers.FirstOrDefault(l => l.Id == currentLayerId) ?? layerFacade.Layers.FirstOrDefault(); + var currentLayerId = layerFacade.CurrentLayer?.Id; + layerFacade.CurrentLayer = layerFacade.Layers.FirstOrDefault(l => l.Id == currentLayerId) ?? layerFacade.Layers.FirstOrDefault(); - messageBus.SendMessage(new LunaDraw.Logic.Messages.CanvasInvalidateMessage()); - } - } + messageBus.SendMessage(new LunaDraw.Logic.Messages.CanvasInvalidateMessage()); + } } diff --git a/Logic/ViewModels/LayerPanelViewModel.cs b/Logic/ViewModels/LayerPanelViewModel.cs index 2dca687..a3dd0d9 100644 --- a/Logic/ViewModels/LayerPanelViewModel.cs +++ b/Logic/ViewModels/LayerPanelViewModel.cs @@ -25,96 +25,165 @@ using System.Collections.Specialized; using System.Reactive; using System.Reactive.Linq; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Services; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using ReactiveUI; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class LayerPanelViewModel : ReactiveObject { - public class LayerPanelViewModel : ReactiveObject + private readonly ILayerFacade layerFacade; + private readonly IMessageBus messageBus; + private readonly IPreferencesService preferencesService; + + public LayerPanelViewModel(ILayerFacade layerFacade, IMessageBus messageBus, IPreferencesService preferencesService) + { + this.layerFacade = layerFacade; + this.messageBus = messageBus; + this.preferencesService = preferencesService; + + layerFacade.WhenAnyValue(x => x.CurrentLayer) + .Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentLayer))); + + // Commands + AddLayerCommand = ReactiveCommand.Create(() => + { + layerFacade.AddLayer(); + }, outputScheduler: RxApp.MainThreadScheduler); + + var layersChanged = Observable.FromEventPattern( + h => layerFacade.Layers.CollectionChanged += h, + h => layerFacade.Layers.CollectionChanged -= h) + .Select(_ => Unit.Default) + .StartWith(Unit.Default); + + var currentLayerChanged = layerFacade.WhenAnyValue(x => x.CurrentLayer) + .Select(_ => Unit.Default); + + var canRemoveLayer = Observable.Merge(layersChanged, currentLayerChanged) + .Select(_ => layerFacade.CurrentLayer != null && layerFacade.Layers.Count > 1) + .ObserveOn(RxApp.MainThreadScheduler); + + RemoveLayerCommand = ReactiveCommand.Create(() => + { + if (layerFacade.CurrentLayer != null) + { + layerFacade.RemoveLayer(layerFacade.CurrentLayer); + } + }, + canExecute: canRemoveLayer, + outputScheduler: RxApp.MainThreadScheduler); + + MoveLayerForwardCommand = ReactiveCommand.Create(layer => + { + layerFacade.MoveLayerForward(layer); + }, outputScheduler: RxApp.MainThreadScheduler); + + MoveLayerBackwardCommand = ReactiveCommand.Create(layer => + { + layerFacade.MoveLayerBackward(layer); + }, outputScheduler: RxApp.MainThreadScheduler); + + ToggleLayerVisibilityCommand = ReactiveCommand.Create(layer => + { + if (layer != null) + { + layer.IsVisible = !layer.IsVisible; + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + }, outputScheduler: RxApp.MainThreadScheduler); + + ToggleLayerLockCommand = ReactiveCommand.Create(layer => + { + if (layer != null) + { + layer.IsLocked = !layer.IsLocked; + } + }, outputScheduler: RxApp.MainThreadScheduler); + + // Initialize state from Preferences + IsTransparentBackground = preferencesService.Get("IsTransparentBackgroundEnabled", false); + if (!IsTransparentBackground) + { + windowTransparency = 255; + } + } + + public ObservableCollection Layers => layerFacade.Layers; + + public Layer? CurrentLayer + { + get => layerFacade.CurrentLayer; + set => layerFacade.CurrentLayer = value; + } + + private bool isTransparentBackground = false; + public bool IsTransparentBackground + { + get => isTransparentBackground; + set { - private readonly ILayerFacade layerFacade; - private readonly IMessageBus messageBus; - - public LayerPanelViewModel(ILayerFacade layerFacade, IMessageBus messageBus) - { - this.layerFacade = layerFacade; - this.messageBus = messageBus; - - layerFacade.WhenAnyValue(x => x.CurrentLayer) - .Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentLayer))); - - // Commands - AddLayerCommand = ReactiveCommand.Create(() => - { - layerFacade.AddLayer(); - }, outputScheduler: RxApp.MainThreadScheduler); - - var layersChanged = Observable.FromEventPattern( - h => layerFacade.Layers.CollectionChanged += h, - h => layerFacade.Layers.CollectionChanged -= h) - .Select(_ => Unit.Default) - .StartWith(Unit.Default); - - var currentLayerChanged = layerFacade.WhenAnyValue(x => x.CurrentLayer) - .Select(_ => Unit.Default); - - var canRemoveLayer = Observable.Merge(layersChanged, currentLayerChanged) - .Select(_ => layerFacade.CurrentLayer != null && layerFacade.Layers.Count > 1) - .ObserveOn(RxApp.MainThreadScheduler); - - RemoveLayerCommand = ReactiveCommand.Create(() => - { - if (layerFacade.CurrentLayer != null) - { - layerFacade.RemoveLayer(layerFacade.CurrentLayer); - } - }, - canExecute: canRemoveLayer, - outputScheduler: RxApp.MainThreadScheduler); - - MoveLayerForwardCommand = ReactiveCommand.Create(layer => - { - layerFacade.MoveLayerForward(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - MoveLayerBackwardCommand = ReactiveCommand.Create(layer => - { - layerFacade.MoveLayerBackward(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - ToggleLayerVisibilityCommand = ReactiveCommand.Create(layer => - { - if (layer != null) - { - layer.IsVisible = !layer.IsVisible; - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - }, outputScheduler: RxApp.MainThreadScheduler); - - ToggleLayerLockCommand = ReactiveCommand.Create(layer => - { - if (layer != null) - { - layer.IsLocked = !layer.IsLocked; - } - }, outputScheduler: RxApp.MainThreadScheduler); - } - - public ObservableCollection Layers => layerFacade.Layers; - - public Layer? CurrentLayer - { - get => layerFacade.CurrentLayer; - set => layerFacade.CurrentLayer = value; - } - - public ReactiveCommand AddLayerCommand { get; } - public ReactiveCommand RemoveLayerCommand { get; } - public ReactiveCommand MoveLayerForwardCommand { get; } - public ReactiveCommand MoveLayerBackwardCommand { get; } - public ReactiveCommand ToggleLayerVisibilityCommand { get; } - public ReactiveCommand ToggleLayerLockCommand { get; } + this.RaiseAndSetIfChanged(ref isTransparentBackground, value); + preferencesService.Set("IsTransparentBackgroundEnabled", value); + + if (!isTransparentBackground) + { + + WindowTransparency = 255; + UpdateWindowTransparency(); + } + + messageBus.SendMessage(new CanvasInvalidateMessage()); } + } + + private byte windowTransparency = 180; + public virtual byte WindowTransparency + { + get => windowTransparency; + set + { + this.RaiseAndSetIfChanged(ref windowTransparency, value); + if (value == 255 && isTransparentBackground) + { + IsTransparentBackground = false; + UpdateWindowTransparency(); + } + else if (value < 255 && !isTransparentBackground) + { + IsTransparentBackground = true; + } + + if (IsTransparentBackground) + { + UpdateWindowTransparency(); + } + } + } + + private void UpdateWindowTransparency() + { +#if WINDOWS + if (IsTransparentBackground) + { + LunaDraw.PlatformHelper.EnableTrueTransparency(WindowTransparency); + } + else + { + LunaDraw.PlatformHelper.EnableTrueTransparency(255); + } +#endif + } + + public static bool IsTransparentBackgroundVisible => Config.FeatureFlags.EnableTransparentBackground; + + public ReactiveCommand AddLayerCommand { get; } + public ReactiveCommand RemoveLayerCommand { get; } + public ReactiveCommand MoveLayerForwardCommand { get; } + public ReactiveCommand MoveLayerBackwardCommand { get; } + public ReactiveCommand ToggleLayerVisibilityCommand { get; } + public ReactiveCommand ToggleLayerLockCommand { get; } } diff --git a/Logic/ViewModels/MainViewModel.cs b/Logic/ViewModels/MainViewModel.cs index 78b10d0..2be2030 100644 --- a/Logic/ViewModels/MainViewModel.cs +++ b/Logic/ViewModels/MainViewModel.cs @@ -22,10 +22,10 @@ */ using System.Collections.ObjectModel; -using System.Reactive; +using System.Windows.Input; using System.Reactive.Linq; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using LunaDraw.Logic.Services; @@ -36,94 +36,140 @@ using SkiaSharp; using SkiaSharp.Views.Maui; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class MainViewModel : ReactiveObject { - public class MainViewModel( - ToolbarViewModel toolbarViewModel, - ILayerFacade layerFacade, - ICanvasInputHandler canvasInputHandler, - NavigationModel navigationModel, - SelectionObserver selectionObserver, - IMessageBus messageBus, - LayerPanelViewModel layerPanelVM, - SelectionViewModel selectionVM, - HistoryViewModel historyVM) : ReactiveObject + // Dependencies + public ToolbarViewModel ToolbarViewModel { get; } + public ILayerFacade LayerFacade { get; } + public ICanvasInputHandler CanvasInputHandler { get; } + public NavigationModel NavigationModel { get; } + public SelectionObserver SelectionObserver { get; } + private readonly IMessageBus messageBus; + + // Sub-ViewModels + public LayerPanelViewModel LayerPanelVM { get; } + public SelectionViewModel SelectionVM { get; } + public HistoryViewModel HistoryVM { get; } + + // Commands + public ICommand ZoomInCommand { get; } + public ICommand ZoomOutCommand { get; } + public ICommand ResetZoomCommand { get; } + + public SKRect CanvasSize { get; set; } + + // Facades for View/CodeBehind access + public ObservableCollection Layers => LayerFacade.Layers; + + public Layer? CurrentLayer { - // Dependencies - public ToolbarViewModel ToolbarViewModel { get; } = toolbarViewModel; - public ILayerFacade LayerFacade { get; } = layerFacade; - public ICanvasInputHandler CanvasInputHandler { get; } = canvasInputHandler; - public NavigationModel NavigationModel { get; } = navigationModel; - public SelectionObserver SelectionObserver { get; } = selectionObserver; - private readonly IMessageBus messageBus = messageBus; + get => LayerFacade.CurrentLayer; + set => LayerFacade.CurrentLayer = value; + } - // Sub-ViewModels - public LayerPanelViewModel LayerPanelVM { get; } = layerPanelVM; - public SelectionViewModel SelectionVM { get; } = selectionVM; - public HistoryViewModel HistoryVM { get; } = historyVM; + public MainViewModel( + ToolbarViewModel toolbarViewModel, + ILayerFacade layerFacade, + ICanvasInputHandler canvasInputHandler, + NavigationModel navigationModel, + SelectionObserver selectionObserver, + IMessageBus messageBus, + LayerPanelViewModel layerPanelVM, + SelectionViewModel selectionVM, + HistoryViewModel historyVM) + { + ToolbarViewModel = toolbarViewModel; + LayerFacade = layerFacade; + CanvasInputHandler = canvasInputHandler; + NavigationModel = navigationModel; + SelectionObserver = selectionObserver; + this.messageBus = messageBus; + LayerPanelVM = layerPanelVM; + SelectionVM = selectionVM; + HistoryVM = historyVM; - public SKRect CanvasSize { get; set; } + ZoomInCommand = ReactiveCommand.Create(ZoomIn); + ZoomOutCommand = ReactiveCommand.Create(ZoomOut); + ResetZoomCommand = ReactiveCommand.Create(ResetZoom); + } - // Facades for View/CodeBehind access - public ObservableCollection Layers => LayerFacade.Layers; - public Layer? CurrentLayer - { - get => LayerFacade.CurrentLayer; - set => LayerFacade.CurrentLayer = value; - } + public IDrawingTool ActiveTool + { + get => ToolbarViewModel.ActiveTool; + set => ToolbarViewModel.ActiveTool = value; + } - public IDrawingTool ActiveTool + public ReadOnlyObservableCollection SelectedElements => SelectionObserver.Selected; + + public void ReorderLayer(Layer source, Layer target) + { + if (source == null || target == null || source == target) return; + int oldIndex = Layers.IndexOf(source); + int newIndex = Layers.IndexOf(target); + if (oldIndex >= 0 && newIndex >= 0) { - get => ToolbarViewModel.ActiveTool; - set => ToolbarViewModel.ActiveTool = value; + LayerFacade.MoveLayer(oldIndex, newIndex); + CurrentLayer = source; } + } - public ReadOnlyObservableCollection SelectedElements => SelectionObserver.Selected; + public void ProcessTouch(SKTouchEventArgs e) + { + CanvasInputHandler.ProcessTouch(e, CanvasSize); + } - public void ReorderLayer(Layer source, Layer target) + public ToolContext CreateToolContext() + { + return new ToolContext { - if (source == null || target == null || source == target) return; - int oldIndex = Layers.IndexOf(source); - int newIndex = Layers.IndexOf(target); - if (oldIndex >= 0 && newIndex >= 0) - { - LayerFacade.MoveLayer(oldIndex, newIndex); - CurrentLayer = source; - } - } + CurrentLayer = LayerFacade.CurrentLayer!, + StrokeColor = ToolbarViewModel.StrokeColor, + FillColor = ToolbarViewModel.FillColor, + StrokeWidth = ToolbarViewModel.StrokeWidth, + Opacity = ToolbarViewModel.Opacity, + Flow = ToolbarViewModel.Flow, + Spacing = ToolbarViewModel.Spacing, + BrushShape = ToolbarViewModel.CurrentBrushShape, + AllElements = LayerFacade.Layers.SelectMany(l => l.Elements), + Layers = LayerFacade.Layers, + SelectionObserver = SelectionObserver, + Scale = NavigationModel.ViewMatrix.ScaleX, + IsGlowEnabled = ToolbarViewModel.IsGlowEnabled, + GlowColor = ToolbarViewModel.GlowColor, + GlowRadius = ToolbarViewModel.GlowRadius, + IsRainbowEnabled = ToolbarViewModel.IsRainbowEnabled, + ScatterRadius = ToolbarViewModel.ScatterRadius, + SizeJitter = ToolbarViewModel.SizeJitter, + AngleJitter = ToolbarViewModel.AngleJitter, + HueJitter = ToolbarViewModel.HueJitter, + CanvasMatrix = NavigationModel.ViewMatrix + }; + } - public void ProcessTouch(SKTouchEventArgs e) - { - CanvasInputHandler.ProcessTouch(e, CanvasSize); - } + private void ZoomIn() => Zoom(1.2f); + private void ZoomOut() => Zoom(1f / 1.2f); - public ToolContext CreateToolContext() - { - return new ToolContext - { - CurrentLayer = LayerFacade.CurrentLayer!, - StrokeColor = ToolbarViewModel.StrokeColor, - FillColor = ToolbarViewModel.FillColor, - StrokeWidth = ToolbarViewModel.StrokeWidth, - Opacity = ToolbarViewModel.Opacity, - Flow = ToolbarViewModel.Flow, - Spacing = ToolbarViewModel.Spacing, - BrushShape = ToolbarViewModel.CurrentBrushShape, - AllElements = LayerFacade.Layers.SelectMany(l => l.Elements), - Layers = LayerFacade.Layers, - SelectionObserver = SelectionObserver, - Scale = NavigationModel.ViewMatrix.ScaleX, - IsGlowEnabled = ToolbarViewModel.IsGlowEnabled, - GlowColor = ToolbarViewModel.GlowColor, - GlowRadius = ToolbarViewModel.GlowRadius, - IsRainbowEnabled = ToolbarViewModel.IsRainbowEnabled, - ScatterRadius = ToolbarViewModel.ScatterRadius, - SizeJitter = ToolbarViewModel.SizeJitter, - AngleJitter = ToolbarViewModel.AngleJitter, - HueJitter = ToolbarViewModel.HueJitter, - CanvasMatrix = NavigationModel.ViewMatrix - }; - } + private void ResetZoom() + { + NavigationModel.Reset(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + } + + private void Zoom(float scaleFactor) + { + if (CanvasSize.Width <= 0 || CanvasSize.Height <= 0) return; + + var center = new SKPoint(CanvasSize.Width / 2, CanvasSize.Height / 2); + + // Scale around center + var zoomMatrix = SKMatrix.CreateScale(scaleFactor, scaleFactor, center.X, center.Y); + + // Apply to existing view matrix + NavigationModel.ViewMatrix = SKMatrix.Concat(zoomMatrix, NavigationModel.ViewMatrix); + + messageBus.SendMessage(new CanvasInvalidateMessage()); } } \ No newline at end of file diff --git a/Logic/ViewModels/SelectionViewModel.cs b/Logic/ViewModels/SelectionViewModel.cs index 14d6d49..a999c49 100644 --- a/Logic/ViewModels/SelectionViewModel.cs +++ b/Logic/ViewModels/SelectionViewModel.cs @@ -24,272 +24,279 @@ using System.Collections.ObjectModel; using System.Reactive; using System.Reactive.Linq; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using ReactiveUI; using SkiaSharp; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class SelectionViewModel : ReactiveObject { - public class SelectionViewModel : ReactiveObject + private readonly SelectionObserver selectionObserver; + private readonly ILayerFacade layerFacade; + private readonly ClipboardMemento clipboardManager; + private readonly IMessageBus messageBus; + + public SelectionViewModel( + SelectionObserver selectionObserver, + ILayerFacade layerFacade, + ClipboardMemento clipboardManager, + IMessageBus messageBus) { - private readonly SelectionObserver selectionObserver; - private readonly ILayerFacade layerFacade; - private readonly ClipboardMemento clipboardManager; - private readonly IMessageBus messageBus; - - public SelectionViewModel( - SelectionObserver selectionObserver, - ILayerFacade layerFacade, - ClipboardMemento clipboardManager, - IMessageBus messageBus) - { - this.selectionObserver = selectionObserver; - this.layerFacade = layerFacade; - this.clipboardManager = clipboardManager; - this.messageBus = messageBus; - - // OAPHs - var hasSelection = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count > 0); - - canDelete = hasSelection.ToProperty(this, x => x.CanDelete); - - canGroup = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count > 1) - .ToProperty(this, x => x.CanGroup); - - canUngroup = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count == 1 && SelectedElements.FirstOrDefault() is DrawableGroup) - .ToProperty(this, x => x.CanUngroup); - - canPaste = this.WhenAnyValue(x => x.clipboardManager.HasItems) - .ToProperty(this, x => x.CanPaste); - - // Commands - DeleteSelectedCommand = ReactiveCommand.Create(DeleteSelected, hasSelection, RxApp.MainThreadScheduler); - GroupSelectedCommand = ReactiveCommand.Create(GroupSelected, this.WhenAnyValue(x => x.CanGroup), RxApp.MainThreadScheduler); - UngroupSelectedCommand = ReactiveCommand.Create(UngroupSelected, this.WhenAnyValue(x => x.CanUngroup), RxApp.MainThreadScheduler); - CopyCommand = ReactiveCommand.Create(Copy, hasSelection, RxApp.MainThreadScheduler); - CutCommand = ReactiveCommand.Create(Cut, hasSelection, RxApp.MainThreadScheduler); - PasteCommand = ReactiveCommand.Create(Paste, this.WhenAnyValue(x => x.CanPaste), RxApp.MainThreadScheduler); - - SendBackwardCommand = ReactiveCommand.Create(SendBackward, hasSelection, RxApp.MainThreadScheduler); - BringForwardCommand = ReactiveCommand.Create(BringForward, hasSelection, RxApp.MainThreadScheduler); - SendElementToBackCommand = ReactiveCommand.Create(SendElementToBack, hasSelection, RxApp.MainThreadScheduler); - BringElementToFrontCommand = ReactiveCommand.Create(BringElementToFront, hasSelection, RxApp.MainThreadScheduler); - MoveSelectionToLayerCommand = ReactiveCommand.Create(MoveSelectionToLayer, hasSelection, RxApp.MainThreadScheduler); - MoveSelectionToNewLayerCommand = ReactiveCommand.Create(MoveSelectionToNewLayer, hasSelection, RxApp.MainThreadScheduler); - } + this.selectionObserver = selectionObserver; + this.layerFacade = layerFacade; + this.clipboardManager = clipboardManager; + this.messageBus = messageBus; + + // OAPHs + var hasSelection = this.WhenAnyValue(x => x.SelectedElements.Count) + .Select(count => count > 0); + + canDelete = hasSelection.ToProperty(this, x => x.CanDelete); + + canGroup = this.WhenAnyValue(x => x.SelectedElements.Count) + .Select(count => count > 1) + .ToProperty(this, x => x.CanGroup); + + canUngroup = this.WhenAnyValue(x => x.SelectedElements.Count) + .Select(count => count == 1 && SelectedElements.FirstOrDefault() is DrawableGroup) + .ToProperty(this, x => x.CanUngroup); + + canPaste = this.WhenAnyValue(x => x.clipboardManager.HasItems) + .ToProperty(this, x => x.CanPaste); + + // Commands + DeleteSelectedCommand = ReactiveCommand.Create(DeleteSelected, hasSelection, RxApp.MainThreadScheduler); + GroupSelectedCommand = ReactiveCommand.Create(GroupSelected, this.WhenAnyValue(x => x.CanGroup), RxApp.MainThreadScheduler); + UngroupSelectedCommand = ReactiveCommand.Create(UngroupSelected, this.WhenAnyValue(x => x.CanUngroup), RxApp.MainThreadScheduler); + CopyCommand = ReactiveCommand.Create(Copy, hasSelection, RxApp.MainThreadScheduler); + CutCommand = ReactiveCommand.Create(Cut, hasSelection, RxApp.MainThreadScheduler); + PasteCommand = ReactiveCommand.Create(Paste, this.WhenAnyValue(x => x.CanPaste), RxApp.MainThreadScheduler); + DuplicateCommand = ReactiveCommand.Create(Duplicate, hasSelection, RxApp.MainThreadScheduler); + + SendBackwardCommand = ReactiveCommand.Create(SendBackward, hasSelection, RxApp.MainThreadScheduler); + BringForwardCommand = ReactiveCommand.Create(BringForward, hasSelection, RxApp.MainThreadScheduler); + SendElementToBackCommand = ReactiveCommand.Create(SendElementToBack, hasSelection, RxApp.MainThreadScheduler); + BringElementToFrontCommand = ReactiveCommand.Create(BringElementToFront, hasSelection, RxApp.MainThreadScheduler); + MoveSelectionToLayerCommand = ReactiveCommand.Create(MoveSelectionToLayer, hasSelection, RxApp.MainThreadScheduler); + MoveSelectionToNewLayerCommand = ReactiveCommand.Create(MoveSelectionToNewLayer, hasSelection, RxApp.MainThreadScheduler); + } - public ReadOnlyObservableCollection SelectedElements => selectionObserver.Selected; + public ReadOnlyObservableCollection SelectedElements => selectionObserver.Selected; - private readonly ObservableAsPropertyHelper canDelete; - public bool CanDelete => canDelete.Value; + private readonly ObservableAsPropertyHelper canDelete; + public bool CanDelete => canDelete.Value; - private readonly ObservableAsPropertyHelper canGroup; - public bool CanGroup => canGroup.Value; + private readonly ObservableAsPropertyHelper canGroup; + public bool CanGroup => canGroup.Value; - private readonly ObservableAsPropertyHelper canUngroup; - public bool CanUngroup => canUngroup.Value; + private readonly ObservableAsPropertyHelper canUngroup; + public bool CanUngroup => canUngroup.Value; - private readonly ObservableAsPropertyHelper canPaste; - public bool CanPaste => canPaste.Value; + private readonly ObservableAsPropertyHelper canPaste; + public bool CanPaste => canPaste.Value; - public ReactiveCommand DeleteSelectedCommand { get; } - public ReactiveCommand GroupSelectedCommand { get; } - public ReactiveCommand UngroupSelectedCommand { get; } - public ReactiveCommand CopyCommand { get; } - public ReactiveCommand CutCommand { get; } - public ReactiveCommand PasteCommand { get; } - public ReactiveCommand SendBackwardCommand { get; } - public ReactiveCommand BringForwardCommand { get; } - public ReactiveCommand SendElementToBackCommand { get; } - public ReactiveCommand BringElementToFrontCommand { get; } - public ReactiveCommand MoveSelectionToLayerCommand { get; } - public ReactiveCommand MoveSelectionToNewLayerCommand { get; } + public ReactiveCommand DeleteSelectedCommand { get; } + public ReactiveCommand GroupSelectedCommand { get; } + public ReactiveCommand UngroupSelectedCommand { get; } + public ReactiveCommand CopyCommand { get; } + public ReactiveCommand CutCommand { get; } + public ReactiveCommand PasteCommand { get; } + public ReactiveCommand DuplicateCommand { get; } + public ReactiveCommand SendBackwardCommand { get; } + public ReactiveCommand BringForwardCommand { get; } + public ReactiveCommand SendElementToBackCommand { get; } + public ReactiveCommand BringElementToFrontCommand { get; } + public ReactiveCommand MoveSelectionToLayerCommand { get; } + public ReactiveCommand MoveSelectionToNewLayerCommand { get; } - private void MoveSelectionToNewLayer() - { - if (!SelectedElements.Any()) return; + private void MoveSelectionToNewLayer() + { + if (!SelectedElements.Any()) return; - layerFacade.AddLayer(); - var newLayer = layerFacade.CurrentLayer; + layerFacade.AddLayer(); + var newLayer = layerFacade.CurrentLayer; - if (newLayer != null) - { - layerFacade.MoveElementsToLayer(SelectedElements, newLayer); - } + if (newLayer != null) + { + layerFacade.MoveElementsToLayer(SelectedElements, newLayer); } + } + + private void MoveSelectionToLayer(Layer targetLayer) + { + if (targetLayer == null || !SelectedElements.Any()) return; + layerFacade.MoveElementsToLayer(SelectedElements, targetLayer); + } + + private void DeleteSelected() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; - private void MoveSelectionToLayer(Layer targetLayer) + var elementsToRemove = SelectedElements.ToList(); + foreach (var element in elementsToRemove) { - if (targetLayer == null || !SelectedElements.Any()) return; - layerFacade.MoveElementsToLayer(SelectedElements, targetLayer); + currentLayer.Elements.Remove(element); } + selectionObserver.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - private void DeleteSelected() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !SelectedElements.Any()) return; + private void GroupSelected() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; - var elementsToRemove = SelectedElements.ToList(); - foreach (var element in elementsToRemove) - { - currentLayer.Elements.Remove(element); - } - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + var elementsToGroup = SelectedElements.ToList(); + var group = new DrawableGroup(); - private void GroupSelected() + foreach (var element in elementsToGroup) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !SelectedElements.Any()) return; - - var elementsToGroup = SelectedElements.ToList(); - var group = new DrawableGroup(); + currentLayer.Elements.Remove(element); + group.Children.Add(element); + } + currentLayer.Elements.Add(group); + selectionObserver.Clear(); + selectionObserver.Add(group); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - foreach (var element in elementsToGroup) + private void UngroupSelected() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null) return; + var group = SelectedElements.FirstOrDefault() as DrawableGroup; + if (group != null) + { + currentLayer.Elements.Remove(group); + foreach (var child in group.Children) { - currentLayer.Elements.Remove(element); - group.Children.Add(element); + currentLayer.Elements.Add(child); } - currentLayer.Elements.Add(group); selectionObserver.Clear(); - selectionObserver.Add(group); messageBus.SendMessage(new CanvasInvalidateMessage()); layerFacade.SaveState(); } + } + + private void Copy() + { + clipboardManager.Copy(SelectedElements); + } - private void UngroupSelected() + private void Cut() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; + clipboardManager.Copy(SelectedElements); + + var elementsToRemove = SelectedElements.ToList(); + foreach (var element in elementsToRemove) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null) return; - var group = SelectedElements.FirstOrDefault() as DrawableGroup; - if (group != null) - { - currentLayer.Elements.Remove(group); - foreach (var child in group.Children) - { - currentLayer.Elements.Add(child); - } - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + currentLayer.Elements.Remove(element); } + selectionObserver.Clear(); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - private void Copy() + private void Paste() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer is null || !clipboardManager.HasItems) return; + foreach (var element in clipboardManager.Paste()) { - clipboardManager.Copy(SelectedElements); + element.Translate(new SKPoint(10, 10)); // Offset pasted element + currentLayer.Elements.Add(element); } + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); + } - private void Cut() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !SelectedElements.Any()) return; - clipboardManager.Copy(SelectedElements); + private void Duplicate() + { + Copy(); + Paste(); + } - var elementsToRemove = SelectedElements.ToList(); - foreach (var element in elementsToRemove) - { - currentLayer.Elements.Remove(element); - } - selectionObserver.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + private void SendBackward() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; + + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - private void Paste() + if (index > 0) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer is null || !clipboardManager.HasItems) return; - foreach (var element in clipboardManager.Paste()) - { - element.Translate(new SKPoint(10, 10)); // Offset pasted element - currentLayer.Elements.Add(element); - } + currentLayer.Elements.Move(index, index - 1); + ReassignZIndices(currentLayer.Elements); messageBus.SendMessage(new CanvasInvalidateMessage()); layerFacade.SaveState(); } + } - private void SendBackward() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); + private void BringForward() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; - if (index > 0) - { - currentLayer.Elements.Move(index, index - 1); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } - } + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - private void BringForward() + if (index < currentLayer.Elements.Count - 1) { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); - - if (index < currentLayer.Elements.Count - 1) - { - currentLayer.Elements.Move(index, index + 1); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + currentLayer.Elements.Move(index, index + 1); + ReassignZIndices(currentLayer.Elements); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); } + } - private void SendElementToBack() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; + private void SendElementToBack() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - if (index > 0) - { - currentLayer.Elements.Move(index, 0); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + if (index > 0) + { + currentLayer.Elements.Move(index, 0); + ReassignZIndices(currentLayer.Elements); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); } + } - private void BringElementToFront() - { - var currentLayer = layerFacade.CurrentLayer; - if (currentLayer == null || !SelectedElements.Any()) return; + private void BringElementToFront() + { + var currentLayer = layerFacade.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; - var selected = SelectedElements.First(); - var index = currentLayer.Elements.IndexOf(selected); + var selected = SelectedElements.First(); + var index = currentLayer.Elements.IndexOf(selected); - if (index < currentLayer.Elements.Count - 1) - { - currentLayer.Elements.Move(index, currentLayer.Elements.Count - 1); - ReassignZIndices(currentLayer.Elements); - messageBus.SendMessage(new CanvasInvalidateMessage()); - layerFacade.SaveState(); - } + if (index < currentLayer.Elements.Count - 1) + { + currentLayer.Elements.Move(index, currentLayer.Elements.Count - 1); + ReassignZIndices(currentLayer.Elements); + messageBus.SendMessage(new CanvasInvalidateMessage()); + layerFacade.SaveState(); } + } - private static void ReassignZIndices(IList elements) + private static void ReassignZIndices(IList elements) + { + for (int i = 0; i < elements.Count; i++) { - for (int i = 0; i < elements.Count; i++) - { - elements[i].ZIndex = i; - } + elements[i].ZIndex = i; } } } diff --git a/Logic/ViewModels/ToolbarViewModel.cs b/Logic/ViewModels/ToolbarViewModel.cs index 2934d24..54c8948 100644 --- a/Logic/ViewModels/ToolbarViewModel.cs +++ b/Logic/ViewModels/ToolbarViewModel.cs @@ -23,12 +23,10 @@ using System.Reactive; using System.Reactive.Linq; -using System.Threading.Tasks; using CommunityToolkit.Maui.Storage; -using Microsoft.Maui.Storage; using LunaDraw.Logic.Models; -using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Tools; @@ -36,494 +34,506 @@ using SkiaSharp; -namespace LunaDraw.Logic.ViewModels +namespace LunaDraw.Logic.ViewModels; + +public class ToolbarViewModel : ReactiveObject { - public class ToolbarViewModel : ReactiveObject + private readonly ILayerFacade layerFacade; + private readonly SelectionViewModel selectionVM; + private readonly HistoryViewModel historyVM; + private readonly IMessageBus messageBus; + private readonly IBitmapCache bitmapCacheManager; + private readonly NavigationModel navigationModel; + private readonly IFileSaver fileSaver; + + // Tool State Properties + private IDrawingTool activeTool; + public virtual IDrawingTool ActiveTool + { + get => activeTool; + set { - private readonly ILayerFacade layerFacade; - private readonly SelectionViewModel selectionVM; - private readonly HistoryViewModel historyVM; - private readonly IMessageBus messageBus; - private readonly IBitmapCache bitmapCacheManager; - private readonly NavigationModel navigationModel; - private readonly IFileSaver fileSaver; - - // Tool State Properties - private IDrawingTool activeTool; - public virtual IDrawingTool ActiveTool - { - get => activeTool; - set - { - this.RaiseAndSetIfChanged(ref activeTool, value); - messageBus.SendMessage(new ToolChangedMessage(value)); - } - } - - private SKColor strokeColor = SKColors.MediumPurple; - public virtual SKColor StrokeColor - { - get => strokeColor; - set => this.RaiseAndSetIfChanged(ref strokeColor, value); - } - - private SKColor? fillColor = SKColors.SteelBlue; - public virtual SKColor? FillColor - { - get => fillColor; - set => this.RaiseAndSetIfChanged(ref fillColor, value); - } - - private float strokeWidth = 40; - public virtual float StrokeWidth - { - get => strokeWidth; - set => this.RaiseAndSetIfChanged(ref strokeWidth, value); - } + this.RaiseAndSetIfChanged(ref activeTool, value); + messageBus.SendMessage(new ToolChangedMessage(value)); + } + } + + private SKColor strokeColor = SKColors.MediumPurple; + public virtual SKColor StrokeColor + { + get => strokeColor; + set => this.RaiseAndSetIfChanged(ref strokeColor, value); + } + + private SKColor? fillColor = SKColors.SteelBlue; + public virtual SKColor? FillColor + { + get => fillColor; + set => this.RaiseAndSetIfChanged(ref fillColor, value); + } + + private float strokeWidth = 40; + public virtual float StrokeWidth + { + get => strokeWidth; + set => this.RaiseAndSetIfChanged(ref strokeWidth, value); + } + + private byte opacity = 255; + public virtual byte Opacity + { + get => opacity; + set => this.RaiseAndSetIfChanged(ref opacity, value); + } + + private byte flow = 255; + public virtual byte Flow + { + get => flow; + set => this.RaiseAndSetIfChanged(ref flow, value); + } + + private float spacing = 1f; + public virtual float Spacing + { + get => spacing; + set => this.RaiseAndSetIfChanged(ref spacing, value); + } + + private BrushShape currentBrushShape; + public virtual BrushShape CurrentBrushShape + { + get => currentBrushShape; + set => this.RaiseAndSetIfChanged(ref currentBrushShape, value); + } + + private bool isGlowEnabled = false; + public virtual bool IsGlowEnabled + { + get => isGlowEnabled; + set => this.RaiseAndSetIfChanged(ref isGlowEnabled, value); + } + + private SKColor glowColor = SKColors.Yellow; + public virtual SKColor GlowColor + { + get => glowColor; + set => this.RaiseAndSetIfChanged(ref glowColor, value); + } + + private float glowRadius = 10f; + public virtual float GlowRadius + { + get => glowRadius; + set => this.RaiseAndSetIfChanged(ref glowRadius, value); + } + + private bool isRainbowEnabled; + public virtual bool IsRainbowEnabled + { + get => isRainbowEnabled; + set => this.RaiseAndSetIfChanged(ref isRainbowEnabled, value); + } + + private float scatterRadius; + public virtual float ScatterRadius + { + get => scatterRadius; + set => this.RaiseAndSetIfChanged(ref scatterRadius, value); + } + + private float sizeJitter; + public virtual float SizeJitter + { + get => sizeJitter; + set => this.RaiseAndSetIfChanged(ref sizeJitter, value); + } + + private float angleJitter; + public virtual float AngleJitter + { + get => angleJitter; + set => this.RaiseAndSetIfChanged(ref angleJitter, value); + } + + private float hueJitter; + public virtual float HueJitter + { + get => hueJitter; + set => this.RaiseAndSetIfChanged(ref hueJitter, value); + } + + public List AvailableTools { get; } + public List AvailableBrushShapes { get; } + + // Delegated Commands + public ReactiveCommand SelectToolCommand { get; } + public ReactiveCommand UndoCommand => historyVM.UndoCommand; + public ReactiveCommand RedoCommand => historyVM.RedoCommand; + public ReactiveCommand CopyCommand => selectionVM.CopyCommand; + public ReactiveCommand PasteCommand => selectionVM.PasteCommand; + public ReactiveCommand DeleteSelectedCommand => selectionVM.DeleteSelectedCommand; + public ReactiveCommand GroupSelectedCommand => selectionVM.GroupSelectedCommand; + public ReactiveCommand UngroupSelectedCommand => selectionVM.UngroupSelectedCommand; + + // Local Commands + public ReactiveCommand ShowSettingsCommand { get; } + public ReactiveCommand ShowShapesFlyoutCommand { get; } + public ReactiveCommand SelectRectangleCommand { get; } + public ReactiveCommand SelectCircleCommand { get; } + public ReactiveCommand SelectLineCommand { get; } + public ReactiveCommand ShowBrushesFlyoutCommand { get; } + public ReactiveCommand SelectBrushShapeCommand { get; } + public ReactiveCommand ImportImageCommand { get; } + public ReactiveCommand SaveImageCommand { get; } + + // UI state properties + private bool isSettingsOpen = false; + public bool IsSettingsOpen + { + get => isSettingsOpen; + set => this.RaiseAndSetIfChanged(ref isSettingsOpen, value); + } + + private bool isShapesFlyoutOpen = false; + public bool IsShapesFlyoutOpen + { + get => isShapesFlyoutOpen; + set => this.RaiseAndSetIfChanged(ref isShapesFlyoutOpen, value); + } + + private bool isBrushesFlyoutOpen = false; + public bool IsBrushesFlyoutOpen + { + get => isBrushesFlyoutOpen; + set => this.RaiseAndSetIfChanged(ref isBrushesFlyoutOpen, value); + } + + private readonly ObservableAsPropertyHelper isAnyFlyoutOpen; + public bool IsAnyFlyoutOpen => isAnyFlyoutOpen.Value; + + private IDrawingTool lastActiveShapeTool; + public IDrawingTool LastActiveShapeTool + { + get => lastActiveShapeTool; + set => this.RaiseAndSetIfChanged(ref lastActiveShapeTool, value); + } + + public ToolbarViewModel( + ILayerFacade layerFacade, + SelectionViewModel selectionVM, + HistoryViewModel historyVM, + IMessageBus messageBus, + IBitmapCache bitmapCacheManager, + NavigationModel navigationModel, + IFileSaver fileSaver) + { + this.layerFacade = layerFacade; + this.selectionVM = selectionVM; + this.historyVM = historyVM; + this.messageBus = messageBus; + this.bitmapCacheManager = bitmapCacheManager; + this.navigationModel = navigationModel; + this.fileSaver = fileSaver; + + // Initialize Tools and Shapes + AvailableTools = + [ + new SelectTool(messageBus), + new FreehandTool(messageBus), + new RectangleTool(messageBus), + new EllipseTool(messageBus), + new LineTool(messageBus), + new FillTool(messageBus), + new EraserBrushTool(messageBus) + ]; + + AvailableBrushShapes = + [ + BrushShape.Circle(), + BrushShape.Square(), + BrushShape.Star(), + BrushShape.Heart(), + BrushShape.Sparkle(), + BrushShape.Cloud(), + BrushShape.Moon(), + BrushShape.Lightning(), + BrushShape.Diamond(), + BrushShape.Triangle(), + BrushShape.Hexagon(), + BrushShape.Unicorn(), + BrushShape.Giraffe(), + BrushShape.Bear(), + BrushShape.Fireworks(), + BrushShape.Flower(), + BrushShape.Sun(), + BrushShape.Snowflake(), + BrushShape.Butterfly(), + BrushShape.Fish(), + BrushShape.Paw(), + BrushShape.Leaf(), + BrushShape.MusicNote(), + BrushShape.Smile() + ]; + + activeTool = new FreehandTool(messageBus); + currentBrushShape = AvailableBrushShapes.First(); + + // Initialize commands + SelectToolCommand = ReactiveCommand.Create(tool => + { + ActiveTool = tool; + }, outputScheduler: RxApp.MainThreadScheduler); - private byte opacity = 255; - public virtual byte Opacity - { - get => opacity; - set => this.RaiseAndSetIfChanged(ref opacity, value); - } + isAnyFlyoutOpen = this.WhenAnyValue(x => x.IsSettingsOpen, x => x.IsShapesFlyoutOpen, x => x.IsBrushesFlyoutOpen) + .Select(values => values.Item1 || values.Item2 || values.Item3) + .ToProperty(this, x => x.IsAnyFlyoutOpen); - private byte flow = 255; - public virtual byte Flow + // Reactive Logic: Close flyouts when ActiveTool changes + this.WhenAnyValue(x => x.ActiveTool) + .Skip(1) // Don't trigger on initialization + .Subscribe(_ => { - get => flow; - set => this.RaiseAndSetIfChanged(ref flow, value); - } + IsBrushesFlyoutOpen = false; + IsShapesFlyoutOpen = false; + IsSettingsOpen = false; + }); - private float spacing = 1f; - public virtual float Spacing - { - get => spacing; - set => this.RaiseAndSetIfChanged(ref spacing, value); - } - - private BrushShape currentBrushShape; - public virtual BrushShape CurrentBrushShape - { - get => currentBrushShape; - set => this.RaiseAndSetIfChanged(ref currentBrushShape, value); - } + // Listen for messages that update tool state + this.messageBus.Listen().Subscribe(msg => + { + if (msg.StrokeColor.HasValue) StrokeColor = msg.StrokeColor.Value; + if (msg.ShouldClearFillColor) FillColor = null; + else if (msg.FillColor.HasValue) FillColor = msg.FillColor.Value; + if (msg.Transparency.HasValue) Opacity = msg.Transparency.Value; + if (msg.Flow.HasValue) Flow = msg.Flow.Value; + if (msg.Spacing.HasValue) Spacing = msg.Spacing.Value; + if (msg.StrokeWidth.HasValue) StrokeWidth = msg.StrokeWidth.Value; + if (msg.IsGlowEnabled.HasValue) IsGlowEnabled = msg.IsGlowEnabled.Value; + if (msg.GlowColor.HasValue) GlowColor = msg.GlowColor.Value; + if (msg.GlowRadius.HasValue) GlowRadius = msg.GlowRadius.Value; + if (msg.IsRainbowEnabled.HasValue) IsRainbowEnabled = msg.IsRainbowEnabled.Value; + if (msg.ScatterRadius.HasValue) ScatterRadius = msg.ScatterRadius.Value; + if (msg.SizeJitter.HasValue) SizeJitter = msg.SizeJitter.Value; + if (msg.AngleJitter.HasValue) AngleJitter = msg.AngleJitter.Value; + if (msg.HueJitter.HasValue) HueJitter = msg.HueJitter.Value; + }); + + this.messageBus.Listen().Subscribe(msg => + { + CurrentBrushShape = msg.Shape; + }); - private bool isGlowEnabled = false; - public virtual bool IsGlowEnabled - { - get => isGlowEnabled; - set => this.RaiseAndSetIfChanged(ref isGlowEnabled, value); - } + lastActiveShapeTool = AvailableTools.FirstOrDefault(t => t is RectangleTool) + ?? AvailableTools.FirstOrDefault(t => t is EllipseTool) + ?? AvailableTools.FirstOrDefault(t => t is LineTool) + ?? new RectangleTool(messageBus); - private SKColor glowColor = SKColors.Yellow; - public virtual SKColor GlowColor - { - get => glowColor; - set => this.RaiseAndSetIfChanged(ref glowColor, value); - } + ShowShapesFlyoutCommand = ReactiveCommand.Create(() => + { + IsSettingsOpen = false; + IsBrushesFlyoutOpen = false; + + if (ActiveTool == LastActiveShapeTool) + { + IsShapesFlyoutOpen = !IsShapesFlyoutOpen; + } + else + { + SelectToolCommand.Execute(LastActiveShapeTool).Subscribe(); + IsShapesFlyoutOpen = false; + } + }); + + ShowBrushesFlyoutCommand = ReactiveCommand.Create(() => + { + IsSettingsOpen = false; + IsShapesFlyoutOpen = false; + + var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); + + if (ActiveTool == freehandTool) + { + IsBrushesFlyoutOpen = !IsBrushesFlyoutOpen; + } + else + { + if (freehandTool != null) + SelectToolCommand.Execute(freehandTool).Subscribe(); + IsBrushesFlyoutOpen = false; + } + }); + + SelectBrushShapeCommand = ReactiveCommand.Create(shape => + { + this.messageBus.SendMessage(new LunaDraw.Logic.Messages.BrushShapeChangedMessage(shape)); + IsBrushesFlyoutOpen = false; - private float glowRadius = 10f; - public virtual float GlowRadius - { - get => glowRadius; - set => this.RaiseAndSetIfChanged(ref glowRadius, value); - } + var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); + if (freehandTool != null && ActiveTool != freehandTool) + { + SelectToolCommand.Execute(freehandTool).Subscribe(); + } + }); - private bool isRainbowEnabled; - public virtual bool IsRainbowEnabled - { - get => isRainbowEnabled; - set => this.RaiseAndSetIfChanged(ref isRainbowEnabled, value); - } + ShowSettingsCommand = ReactiveCommand.Create(() => + { + IsSettingsOpen = !IsSettingsOpen; + IsShapesFlyoutOpen = false; + IsBrushesFlyoutOpen = false; + }); - private float scatterRadius; - public virtual float ScatterRadius - { - get => scatterRadius; - set => this.RaiseAndSetIfChanged(ref scatterRadius, value); - } + SelectRectangleCommand = ReactiveCommand.Create(() => + { + var tool = AvailableTools.FirstOrDefault(t => t is RectangleTool) ?? new RectangleTool(messageBus); + LastActiveShapeTool = tool; + SelectToolCommand.Execute(tool).Subscribe(); + IsShapesFlyoutOpen = false; + }); - private float sizeJitter; - public virtual float SizeJitter - { - get => sizeJitter; - set => this.RaiseAndSetIfChanged(ref sizeJitter, value); - } + SelectCircleCommand = ReactiveCommand.Create(() => + { + var tool = AvailableTools.FirstOrDefault(t => t is EllipseTool) ?? new EllipseTool(messageBus); + LastActiveShapeTool = tool; + SelectToolCommand.Execute(tool).Subscribe(); + IsShapesFlyoutOpen = false; + }); - private float angleJitter; - public virtual float AngleJitter - { - get => angleJitter; - set => this.RaiseAndSetIfChanged(ref angleJitter, value); - } + SelectLineCommand = ReactiveCommand.Create(() => + { + var tool = AvailableTools.FirstOrDefault(t => t is LineTool) ?? new LineTool(messageBus); + LastActiveShapeTool = tool; + SelectToolCommand.Execute(tool).Subscribe(); + IsShapesFlyoutOpen = false; + }); - private float hueJitter; - public virtual float HueJitter + ImportImageCommand = ReactiveCommand.CreateFromTask(async () => + { + try + { + var result = await FilePicker.Default.PickAsync(new PickOptions { - get => hueJitter; - set => this.RaiseAndSetIfChanged(ref hueJitter, value); - } + PickerTitle = "Select an image to import", + FileTypes = FilePickerFileType.Images + }); - public List AvailableTools { get; } - public List AvailableBrushShapes { get; } - - // Delegated Commands - public ReactiveCommand SelectToolCommand { get; } - public ReactiveCommand UndoCommand => historyVM.UndoCommand; - public ReactiveCommand RedoCommand => historyVM.RedoCommand; - public ReactiveCommand CopyCommand => selectionVM.CopyCommand; - public ReactiveCommand PasteCommand => selectionVM.PasteCommand; - public ReactiveCommand DeleteSelectedCommand => selectionVM.DeleteSelectedCommand; - public ReactiveCommand GroupSelectedCommand => selectionVM.GroupSelectedCommand; - public ReactiveCommand UngroupSelectedCommand => selectionVM.UngroupSelectedCommand; - - // Local Commands - public ReactiveCommand ShowSettingsCommand { get; } - public ReactiveCommand ShowShapesFlyoutCommand { get; } - public ReactiveCommand SelectRectangleCommand { get; } - public ReactiveCommand SelectCircleCommand { get; } - public ReactiveCommand SelectLineCommand { get; } - public ReactiveCommand ShowBrushesFlyoutCommand { get; } - public ReactiveCommand SelectBrushShapeCommand { get; } - public ReactiveCommand ImportImageCommand { get; } - public ReactiveCommand SaveImageCommand { get; } - - // UI state properties - private bool isSettingsOpen = false; - public bool IsSettingsOpen + if (result != null) { - get => isSettingsOpen; - set => this.RaiseAndSetIfChanged(ref isSettingsOpen, value); - } + string path = result.FullPath; + + // On platforms where FullPath is not available, copy to cache + if (string.IsNullOrEmpty(path)) + { + path = Path.Combine(FileSystem.CacheDirectory, result.FileName); + using var sourceStream = await result.OpenReadAsync(); + using var destStream = File.Create(path); + await sourceStream.CopyToAsync(destStream); + } + + // Load with downsampling (max 2048x2048) + var bitmap = await this.bitmapCacheManager.GetBitmapAsync(path, 2048, 2048); + + if (bitmap != null) + { + var drawableImage = new DrawableImage(bitmap) + { + SourcePath = path + }; - private bool isShapesFlyoutOpen = false; - public bool IsShapesFlyoutOpen - { - get => isShapesFlyoutOpen; - set => this.RaiseAndSetIfChanged(ref isShapesFlyoutOpen, value); + this.layerFacade.CurrentLayer?.Elements.Add(drawableImage); + this.messageBus.SendMessage(new CanvasInvalidateMessage()); + this.layerFacade.SaveState(); + } } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error importing image: {ex.Message}"); + } + }); + + SaveImageCommand = ReactiveCommand.CreateFromTask(async () => + { + try + { + if (this.navigationModel.CanvasWidth <= 0 || this.navigationModel.CanvasHeight <= 0) + return; - private bool isBrushesFlyoutOpen = false; - public bool IsBrushesFlyoutOpen - { - get => isBrushesFlyoutOpen; - set => this.RaiseAndSetIfChanged(ref isBrushesFlyoutOpen, value); - } + using var surface = SKSurface.Create(new SKImageInfo((int)this.navigationModel.CanvasWidth, (int)this.navigationModel.CanvasHeight)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.White); - private readonly ObservableAsPropertyHelper isAnyFlyoutOpen; - public bool IsAnyFlyoutOpen => isAnyFlyoutOpen.Value; + canvas.Save(); - private IDrawingTool lastActiveShapeTool; - public IDrawingTool LastActiveShapeTool - { - get => lastActiveShapeTool; - set => this.RaiseAndSetIfChanged(ref lastActiveShapeTool, value); - } + // Apply the view transformation matrix + canvas.SetMatrix(this.navigationModel.ViewMatrix); - public ToolbarViewModel( - ILayerFacade layerFacade, - SelectionViewModel selectionVM, - HistoryViewModel historyVM, - IMessageBus messageBus, - IBitmapCache bitmapCacheManager, - NavigationModel navigationModel, - IFileSaver fileSaver) + // Draw layers with masking support + var layers = this.layerFacade.Layers; + for (int i = 0; i < layers.Count; i++) { - this.layerFacade = layerFacade; - this.selectionVM = selectionVM; - this.historyVM = historyVM; - this.messageBus = messageBus; - this.bitmapCacheManager = bitmapCacheManager; - this.navigationModel = navigationModel; - this.fileSaver = fileSaver; - - // Initialize Tools and Shapes - AvailableTools = - [ - new SelectTool(messageBus), - new FreehandTool(messageBus), - new RectangleTool(messageBus), - new EllipseTool(messageBus), - new LineTool(messageBus), - new FillTool(messageBus), - new EraserBrushTool(messageBus) - ]; - - AvailableBrushShapes = - [ - BrushShape.Circle(), - BrushShape.Square(), - BrushShape.Star(), - BrushShape.Heart(), - BrushShape.Sparkle(), - BrushShape.Cloud(), - BrushShape.Moon(), - BrushShape.Lightning(), - BrushShape.Diamond(), - BrushShape.Triangle(), - BrushShape.Hexagon() - ]; - - activeTool = new FreehandTool(messageBus); - currentBrushShape = AvailableBrushShapes.First(); - - // Initialize commands - SelectToolCommand = ReactiveCommand.Create(tool => - { - ActiveTool = tool; - }, outputScheduler: RxApp.MainThreadScheduler); - - isAnyFlyoutOpen = this.WhenAnyValue(x => x.IsSettingsOpen, x => x.IsShapesFlyoutOpen, x => x.IsBrushesFlyoutOpen) - .Select(values => values.Item1 || values.Item2 || values.Item3) - .ToProperty(this, x => x.IsAnyFlyoutOpen); - - // Reactive Logic: Close flyouts when ActiveTool changes - this.WhenAnyValue(x => x.ActiveTool) - .Skip(1) // Don't trigger on initialization - .Subscribe(_ => - { - IsBrushesFlyoutOpen = false; - IsShapesFlyoutOpen = false; - IsSettingsOpen = false; - }); - - // Listen for messages that update tool state - this.messageBus.Listen().Subscribe(msg => - { - if (msg.StrokeColor.HasValue) StrokeColor = msg.StrokeColor.Value; - if (msg.ShouldClearFillColor) FillColor = null; - else if (msg.FillColor.HasValue) FillColor = msg.FillColor.Value; - if (msg.Transparency.HasValue) Opacity = msg.Transparency.Value; - if (msg.Flow.HasValue) Flow = msg.Flow.Value; - if (msg.Spacing.HasValue) Spacing = msg.Spacing.Value; - if (msg.StrokeWidth.HasValue) StrokeWidth = msg.StrokeWidth.Value; - if (msg.IsGlowEnabled.HasValue) IsGlowEnabled = msg.IsGlowEnabled.Value; - if (msg.GlowColor.HasValue) GlowColor = msg.GlowColor.Value; - if (msg.GlowRadius.HasValue) GlowRadius = msg.GlowRadius.Value; - if (msg.IsRainbowEnabled.HasValue) IsRainbowEnabled = msg.IsRainbowEnabled.Value; - if (msg.ScatterRadius.HasValue) ScatterRadius = msg.ScatterRadius.Value; - if (msg.SizeJitter.HasValue) SizeJitter = msg.SizeJitter.Value; - if (msg.AngleJitter.HasValue) AngleJitter = msg.AngleJitter.Value; - if (msg.HueJitter.HasValue) HueJitter = msg.HueJitter.Value; - }); - - this.messageBus.Listen().Subscribe(msg => - { - CurrentBrushShape = msg.Shape; - }); - - lastActiveShapeTool = AvailableTools.FirstOrDefault(t => t is RectangleTool) - ?? AvailableTools.FirstOrDefault(t => t is EllipseTool) - ?? AvailableTools.FirstOrDefault(t => t is LineTool) - ?? new RectangleTool(messageBus); - - ShowShapesFlyoutCommand = ReactiveCommand.Create(() => + var layer = layers[i]; + if (!layer.IsVisible) continue; + + if (layer.MaskingMode == Logic.Models.MaskingMode.Clip) + { + layer.Draw(canvas); + } + else + { + // Check if next layers are clipping layers + bool hasClippingLayers = false; + int nextIndex = i + 1; + while (nextIndex < layers.Count && layers[nextIndex].MaskingMode == Logic.Models.MaskingMode.Clip) { - IsSettingsOpen = false; - IsBrushesFlyoutOpen = false; - - if (ActiveTool == LastActiveShapeTool) - { - IsShapesFlyoutOpen = !IsShapesFlyoutOpen; - } - else - { - SelectToolCommand.Execute(LastActiveShapeTool).Subscribe(); - IsShapesFlyoutOpen = false; - } - }); - - ShowBrushesFlyoutCommand = ReactiveCommand.Create(() => - { - IsSettingsOpen = false; - IsShapesFlyoutOpen = false; - - var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); - - if (ActiveTool == freehandTool) - { - IsBrushesFlyoutOpen = !IsBrushesFlyoutOpen; - } - else - { - if (freehandTool != null) - SelectToolCommand.Execute(freehandTool).Subscribe(); - IsBrushesFlyoutOpen = false; - } - }); + if (layers[nextIndex].IsVisible) hasClippingLayers = true; + nextIndex++; + } - SelectBrushShapeCommand = ReactiveCommand.Create(shape => + if (hasClippingLayers) { - this.messageBus.SendMessage(new LunaDraw.Logic.Messages.BrushShapeChangedMessage(shape)); - IsBrushesFlyoutOpen = false; + canvas.SaveLayer(); + layer.Draw(canvas); - var freehandTool = AvailableTools.FirstOrDefault(t => t.Type == ToolType.Freehand); - if (freehandTool != null && ActiveTool != freehandTool) + using (var paint = new SKPaint { BlendMode = SKBlendMode.SrcATop }) + { + for (int j = i + 1; j < layers.Count; j++) { - SelectToolCommand.Execute(freehandTool).Subscribe(); - } - }); - - ShowSettingsCommand = ReactiveCommand.Create(() => - { - IsSettingsOpen = !IsSettingsOpen; - IsShapesFlyoutOpen = false; - IsBrushesFlyoutOpen = false; - }); - - SelectRectangleCommand = ReactiveCommand.Create(() => - { - var tool = AvailableTools.FirstOrDefault(t => t is RectangleTool) ?? new RectangleTool(messageBus); - LastActiveShapeTool = tool; - SelectToolCommand.Execute(tool).Subscribe(); - IsShapesFlyoutOpen = false; - }); + var clipLayer = layers[j]; + if (clipLayer.MaskingMode != Logic.Models.MaskingMode.Clip) break; - SelectCircleCommand = ReactiveCommand.Create(() => - { - var tool = AvailableTools.FirstOrDefault(t => t is EllipseTool) ?? new EllipseTool(messageBus); - LastActiveShapeTool = tool; - SelectToolCommand.Execute(tool).Subscribe(); - IsShapesFlyoutOpen = false; - }); - - SelectLineCommand = ReactiveCommand.Create(() => - { - var tool = AvailableTools.FirstOrDefault(t => t is LineTool) ?? new LineTool(messageBus); - LastActiveShapeTool = tool; - SelectToolCommand.Execute(tool).Subscribe(); - IsShapesFlyoutOpen = false; - }); + if (clipLayer.IsVisible) + { + canvas.SaveLayer(paint); + clipLayer.Draw(canvas); + canvas.Restore(); + } - ImportImageCommand = ReactiveCommand.CreateFromTask(async () => - { - try - { - var result = await FilePicker.Default.PickAsync(new PickOptions - { - PickerTitle = "Select an image to import", - FileTypes = FilePickerFileType.Images - }); - - if (result != null) - { - string path = result.FullPath; - - // On platforms where FullPath is not available, copy to cache - if (string.IsNullOrEmpty(path)) - { - path = Path.Combine(FileSystem.CacheDirectory, result.FileName); - using var sourceStream = await result.OpenReadAsync(); - using var destStream = File.Create(path); - await sourceStream.CopyToAsync(destStream); - } - - // Load with downsampling (max 2048x2048) - var bitmap = await this.bitmapCacheManager.GetBitmapAsync(path, 2048, 2048); - - if (bitmap != null) - { - var drawableImage = new DrawableImage(bitmap) - { - SourcePath = path - }; - - this.layerFacade.CurrentLayer?.Elements.Add(drawableImage); - this.messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerFacade.SaveState(); - } - } + i = j; } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error importing image: {ex.Message}"); - } - }); + } - SaveImageCommand = ReactiveCommand.CreateFromTask(async () => + canvas.Restore(); + } + else { - try - { - if (this.navigationModel.CanvasWidth <= 0 || this.navigationModel.CanvasHeight <= 0) - return; - - using var surface = SKSurface.Create(new SKImageInfo((int)this.navigationModel.CanvasWidth, (int)this.navigationModel.CanvasHeight)); - var canvas = surface.Canvas; - canvas.Clear(SKColors.White); - - canvas.Save(); - - // Apply the view transformation matrix - canvas.SetMatrix(this.navigationModel.ViewMatrix); - - // Draw layers with masking support - var layers = this.layerFacade.Layers; - for (int i = 0; i < layers.Count; i++) - { - var layer = layers[i]; - if (!layer.IsVisible) continue; - - if (layer.MaskingMode == Logic.Models.MaskingMode.Clip) - { - layer.Draw(canvas); - } - else - { - // Check if next layers are clipping layers - bool hasClippingLayers = false; - int nextIndex = i + 1; - while (nextIndex < layers.Count && layers[nextIndex].MaskingMode == Logic.Models.MaskingMode.Clip) - { - if (layers[nextIndex].IsVisible) hasClippingLayers = true; - nextIndex++; - } - - if (hasClippingLayers) - { - canvas.SaveLayer(); - layer.Draw(canvas); - - using (var paint = new SKPaint { BlendMode = SKBlendMode.SrcATop }) - { - for (int j = i + 1; j < layers.Count; j++) - { - var clipLayer = layers[j]; - if (clipLayer.MaskingMode != Logic.Models.MaskingMode.Clip) break; - - if (clipLayer.IsVisible) - { - canvas.SaveLayer(paint); - clipLayer.Draw(canvas); - canvas.Restore(); - } - - i = j; - } - } - - canvas.Restore(); - } - else - { - layer.Draw(canvas); - } - } - } + layer.Draw(canvas); + } + } + } - canvas.Restore(); + canvas.Restore(); - using var image = surface.Snapshot(); - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - using var stream = data.AsStream(); + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + using var stream = data.AsStream(); - var result = await this.fileSaver.SaveAsync("lunadraw_canvas.png", stream); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error saving image: {ex.Message}"); - } - }); - } - } + var result = await this.fileSaver.SaveAsync("lunadraw_canvas.png", stream); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error saving image: {ex.Message}"); + } + }); + } } \ No newline at end of file diff --git a/LunaDraw.csproj b/LunaDraw.csproj index 7e441cc..e622d49 100644 --- a/LunaDraw.csproj +++ b/LunaDraw.csproj @@ -41,10 +41,13 @@ 10.0.17763.0 10.0.17763.0 6.5 - $(DefaultItemExcludes);legacy\** $(DefaultItemExcludes);Tests\** + + false + + @@ -65,13 +68,14 @@ - + - - - - + + + + + diff --git a/MauiProgram.cs b/MauiProgram.cs index b36ae70..e84f7dc 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -23,7 +23,8 @@ using CommunityToolkit.Maui; using CommunityToolkit.Maui.Storage; -using LunaDraw.Logic.Managers; +using Microsoft.Maui.LifecycleEvents; +using LunaDraw.Logic.Utils; using LunaDraw.Logic.Models; using LunaDraw.Logic.Services; using LunaDraw.Logic.ViewModels; @@ -34,6 +35,10 @@ using SkiaSharp.Views.Maui.Controls.Hosting; using Splat; +#if WINDOWS +using Microsoft.UI.Xaml.Media; +#endif + namespace LunaDraw; public static class MauiProgram @@ -54,10 +59,26 @@ public static MauiApp CreateMauiApp() { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }) + .ConfigureLifecycleEvents(events => + { +#if WINDOWS + events.AddWindows(wndLifeCycleBuilder => + { + wndLifeCycleBuilder.OnWindowCreated(window => + { + window.SystemBackdrop = new WinUI.TransparentBackdrop(); + if (Microsoft.Maui.Storage.Preferences.Get("IsTransparentBackgroundEnabled", false)) + { + PlatformHelper.EnableTrueTransparency(180); // Fully transparent + } + }); + }); +#endif }); // Register Core State Managers - builder.Services.AddSingleton(new ReactiveUI.MessageBus()); + builder.Services.AddSingleton(new MessageBus()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -65,7 +86,8 @@ public static MauiApp CreateMauiApp() // Register Logic Services builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(FileSaver.Default); // Register ViewModels diff --git a/Pages/MainPage.xaml b/Pages/MainPage.xaml index decaa7b..7832a39 100644 --- a/Pages/MainPage.xaml +++ b/Pages/MainPage.xaml @@ -28,7 +28,7 @@ Touch="OnTouch" IgnorePixelScaling="False"> - + @@ -60,22 +60,45 @@ BindingContext="{Binding Source={x:Reference toolbarView}, Path=BindingContext}"/> + + + + + +