From 6be0e4fa598cd9b1a30ac99c95528494b8f08960 Mon Sep 17 00:00:00 2001 From: Jeff H Date: Sat, 6 Dec 2025 22:14:05 -0500 Subject: [PATCH 1/6] main view model refactor --- Logic/Managers/ClipboardManager.cs | 23 + Logic/Tools/EraserBrushTool.cs | 2 +- Logic/Tools/EraserTool.cs | 2 +- Logic/Tools/FillTool.cs | 2 +- Logic/Tools/FreehandTool.cs | 28 +- Logic/Tools/IDrawingTool.cs | 2 +- Logic/Tools/LineTool.cs | 2 +- Logic/Tools/SelectTool.cs | 15 +- Logic/Tools/ShapeTool.cs | 2 +- Logic/ViewModels/HistoryViewModel.cs | 73 +++ Logic/ViewModels/LayerPanelViewModel.cs | 77 +++ Logic/ViewModels/MainViewModel.cs | 610 ++---------------- Logic/ViewModels/SelectionViewModel.cs | 274 ++++++++ Logic/ViewModels/ToolbarViewModel.cs | 110 ++-- MauiProgram.cs | 5 + Pages/MainPage.xaml.cs | 17 +- .../LunaDraw.Tests/LayerStateManagerTests.cs | 1 + tests/LunaDraw.Tests/MainViewModelTests.cs | 145 +++++ .../SelectToolInteractionTests.cs | 12 +- tests/LunaDraw.Tests/SelectToolTests.cs | 6 +- 20 files changed, 775 insertions(+), 633 deletions(-) create mode 100644 Logic/Managers/ClipboardManager.cs create mode 100644 Logic/ViewModels/HistoryViewModel.cs create mode 100644 Logic/ViewModels/LayerPanelViewModel.cs create mode 100644 Logic/ViewModels/SelectionViewModel.cs create mode 100644 tests/LunaDraw.Tests/MainViewModelTests.cs diff --git a/Logic/Managers/ClipboardManager.cs b/Logic/Managers/ClipboardManager.cs new file mode 100644 index 0000000..adff8dc --- /dev/null +++ b/Logic/Managers/ClipboardManager.cs @@ -0,0 +1,23 @@ +using LunaDraw.Logic.Models; +using ReactiveUI; + +namespace LunaDraw.Logic.Managers +{ + public class ClipboardManager : ReactiveObject + { + private List _clipboard = new(); + + 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 bool HasItems => _clipboard.Count > 0; + } +} diff --git a/Logic/Tools/EraserBrushTool.cs b/Logic/Tools/EraserBrushTool.cs index 8ae487f..70b69b1 100644 --- a/Logic/Tools/EraserBrushTool.cs +++ b/Logic/Tools/EraserBrushTool.cs @@ -223,7 +223,7 @@ public void OnTouchCancelled(ToolContext context) messageBus.SendMessage(new CanvasInvalidateMessage()); } - public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public void DrawPreview(SKCanvas canvas, ToolContext context) { // Optional: Draw a circle cursor for eraser size } diff --git a/Logic/Tools/EraserTool.cs b/Logic/Tools/EraserTool.cs index cefa38c..5263ebe 100644 --- a/Logic/Tools/EraserTool.cs +++ b/Logic/Tools/EraserTool.cs @@ -62,7 +62,7 @@ private void Erase(SKPoint point, ToolContext context) } } - public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public void DrawPreview(SKCanvas canvas, ToolContext context) { } } diff --git a/Logic/Tools/FillTool.cs b/Logic/Tools/FillTool.cs index 46455f3..7a6d75b 100644 --- a/Logic/Tools/FillTool.cs +++ b/Logic/Tools/FillTool.cs @@ -48,7 +48,7 @@ public void OnTouchCancelled(ToolContext context) { } - public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public void DrawPreview(SKCanvas canvas, ToolContext context) { } } diff --git a/Logic/Tools/FreehandTool.cs b/Logic/Tools/FreehandTool.cs index 0715ded..a2e6f11 100644 --- a/Logic/Tools/FreehandTool.cs +++ b/Logic/Tools/FreehandTool.cs @@ -127,18 +127,18 @@ public void OnTouchCancelled(ToolContext context) messageBus.SendMessage(new CanvasInvalidateMessage()); } - public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public void DrawPreview(SKCanvas canvas, ToolContext context) { if (currentPoints == null || currentPoints.Count == 0) return; - // Get current shape from viewModel - var shape = viewModel.CurrentBrushShape; + // Get current shape from context + var shape = context.BrushShape; if (shape?.Path == null) return; - float size = viewModel.StrokeWidth; + float size = context.StrokeWidth; float baseScale = size / 20f; - byte flow = viewModel.Flow; - byte opacity = viewModel.Opacity; + byte flow = context.Flow; + byte opacity = context.Opacity; using var scaledPath = new SKPath(shape.Path); var scaleMatrix = SKMatrix.CreateScale(baseScale, baseScale); @@ -161,16 +161,16 @@ public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) var random = new Random(index * 1337); // Calculate color - SKColor color = viewModel.StrokeColor; - if (viewModel.IsRainbowEnabled) + SKColor color = context.StrokeColor; + if (context.IsRainbowEnabled) { float hue = index * 10 % 360; color = SKColor.FromHsl(hue, 100, 50); } - else if (viewModel.HueJitter > 0) + 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 * viewModel.HueJitter * 360f; + 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); @@ -188,16 +188,16 @@ public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) } // Rotation Jitter - if (viewModel.AngleJitter > 0) + if (context.AngleJitter > 0) { - float rotation = ((float)random.NextDouble() - 0.5f) * 2.0f * viewModel.AngleJitter; + float rotation = ((float)random.NextDouble() - 0.5f) * 2.0f * context.AngleJitter; canvas.RotateDegrees(rotation); } // Size Jitter - if (viewModel.SizeJitter > 0) + if (context.SizeJitter > 0) { - float scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * viewModel.SizeJitter; + float scaleFactor = 1.0f + ((float)random.NextDouble() - 0.5f) * 2.0f * context.SizeJitter; if (scaleFactor < 0.1f) scaleFactor = 0.1f; canvas.Scale(scaleFactor); } diff --git a/Logic/Tools/IDrawingTool.cs b/Logic/Tools/IDrawingTool.cs index 9b3c86a..6eca7ab 100644 --- a/Logic/Tools/IDrawingTool.cs +++ b/Logic/Tools/IDrawingTool.cs @@ -26,6 +26,6 @@ public interface IDrawingTool void OnTouchMoved(SKPoint point, ToolContext context); void OnTouchReleased(SKPoint point, ToolContext context); void OnTouchCancelled(ToolContext context); - void DrawPreview(SKCanvas canvas, MainViewModel viewModel); + void DrawPreview(SKCanvas canvas, ToolContext context); } } diff --git a/Logic/Tools/LineTool.cs b/Logic/Tools/LineTool.cs index f6520e1..aa9cffd 100644 --- a/Logic/Tools/LineTool.cs +++ b/Logic/Tools/LineTool.cs @@ -66,7 +66,7 @@ public void OnTouchCancelled(ToolContext context) messageBus.SendMessage(new CanvasInvalidateMessage()); } - public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public void DrawPreview(SKCanvas canvas, ToolContext context) { if (currentLine != null) { diff --git a/Logic/Tools/SelectTool.cs b/Logic/Tools/SelectTool.cs index 6455eea..e13ccfb 100644 --- a/Logic/Tools/SelectTool.cs +++ b/Logic/Tools/SelectTool.cs @@ -148,11 +148,11 @@ public void OnTouchCancelled(ToolContext context) messageBus.SendMessage(new CanvasInvalidateMessage()); } - public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public void DrawPreview(SKCanvas canvas, ToolContext context) { - if (viewModel.SelectionManager.Selected.Any()) + if (context.SelectionManager.Selected.Any()) { - var bounds = viewModel.SelectionManager.GetBounds(); + var bounds = context.SelectionManager.GetBounds(); if (bounds.IsEmpty) return; // Draw selection rectangle @@ -168,7 +168,14 @@ public void DrawPreview(SKCanvas canvas, MainViewModel viewModel) // Draw resize handles if (currentState != SelectionState.Resizing) { - float handleDrawScale = 1.0f / viewModel.NavigationModel.TotalMatrix.ScaleX; // Inverse of canvas scale + 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); + DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Top), handleDrawScale); DrawResizeHandle(canvas, new SKPoint(bounds.Right, bounds.Top), handleDrawScale); DrawResizeHandle(canvas, new SKPoint(bounds.Left, bounds.Bottom), handleDrawScale); diff --git a/Logic/Tools/ShapeTool.cs b/Logic/Tools/ShapeTool.cs index 321451d..838223e 100644 --- a/Logic/Tools/ShapeTool.cs +++ b/Logic/Tools/ShapeTool.cs @@ -64,7 +64,7 @@ public virtual void OnTouchCancelled(ToolContext context) MessageBus.SendMessage(new CanvasInvalidateMessage()); } - public virtual void DrawPreview(SKCanvas canvas, MainViewModel viewModel) + public virtual void DrawPreview(SKCanvas canvas, ToolContext context) { if (CurrentShape != null) { diff --git a/Logic/ViewModels/HistoryViewModel.cs b/Logic/ViewModels/HistoryViewModel.cs new file mode 100644 index 0000000..327a46f --- /dev/null +++ b/Logic/ViewModels/HistoryViewModel.cs @@ -0,0 +1,73 @@ +using System.Reactive; +using LunaDraw.Logic.Managers; +using ReactiveUI; +using LunaDraw.Logic.Models; + +namespace LunaDraw.Logic.ViewModels +{ + public class HistoryViewModel : ReactiveObject + { + private readonly HistoryManager _historyManager; + private readonly ILayerStateManager _layerStateManager; + private readonly IMessageBus _messageBus; + + public HistoryViewModel(ILayerStateManager layerStateManager, IMessageBus messageBus) + { + _layerStateManager = layerStateManager; + _historyManager = layerStateManager.HistoryManager; + _messageBus = messageBus; + + // Observables for CanUndo/CanRedo + var canUndo = this.WhenAnyValue(x => x._historyManager.CanUndo); + var canRedo = this.WhenAnyValue(x => x._historyManager.CanRedo); + + 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); + } + + private readonly ObservableAsPropertyHelper canUndoProp; + public bool CanUndo => canUndoProp.Value; + + private readonly ObservableAsPropertyHelper canRedoProp; + public bool CanRedo => canRedoProp.Value; + + public ReactiveCommand UndoCommand { get; } + public ReactiveCommand RedoCommand { get; } + + private void Undo() + { + var state = _historyManager.Undo(); + if (state != null) + { + RestoreState(state); + } + } + + private void Redo() + { + var state = _historyManager.Redo(); + if (state != null) + { + RestoreState(state); + } + } + + private void RestoreState(List state) + { + _layerStateManager.Layers.Clear(); + foreach (var layer in state) + { + _layerStateManager.Layers.Add(layer); + } + + var currentLayerId = _layerStateManager.CurrentLayer?.Id; + _layerStateManager.CurrentLayer = _layerStateManager.Layers.FirstOrDefault(l => l.Id == currentLayerId) ?? _layerStateManager.Layers.FirstOrDefault(); + + _messageBus.SendMessage(new LunaDraw.Logic.Messages.CanvasInvalidateMessage()); + } + } +} diff --git a/Logic/ViewModels/LayerPanelViewModel.cs b/Logic/ViewModels/LayerPanelViewModel.cs new file mode 100644 index 0000000..ec70ad3 --- /dev/null +++ b/Logic/ViewModels/LayerPanelViewModel.cs @@ -0,0 +1,77 @@ +using System.Collections.ObjectModel; +using System.Reactive; +using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Messages; +using LunaDraw.Logic.Models; +using ReactiveUI; + +namespace LunaDraw.Logic.ViewModels +{ + public class LayerPanelViewModel : ReactiveObject + { + private readonly ILayerStateManager _layerStateManager; + private readonly IMessageBus _messageBus; + + public LayerPanelViewModel(ILayerStateManager layerStateManager, IMessageBus messageBus) + { + _layerStateManager = layerStateManager; + _messageBus = messageBus; + + _layerStateManager.WhenAnyValue(x => x.CurrentLayer) + .Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentLayer))); + + // Commands + AddLayerCommand = ReactiveCommand.Create(() => + { + _layerStateManager.AddLayer(); + }, outputScheduler: RxApp.MainThreadScheduler); + + RemoveLayerCommand = ReactiveCommand.Create(layer => + { + _layerStateManager.RemoveLayer(layer); + }, outputScheduler: RxApp.MainThreadScheduler); + + MoveLayerForwardCommand = ReactiveCommand.Create(layer => + { + _layerStateManager.MoveLayerForward(layer); + }, outputScheduler: RxApp.MainThreadScheduler); + + MoveLayerBackwardCommand = ReactiveCommand.Create(layer => + { + _layerStateManager.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 => _layerStateManager.Layers; + + public Layer? CurrentLayer + { + get => _layerStateManager.CurrentLayer; + set => _layerStateManager.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; } + } +} diff --git a/Logic/ViewModels/MainViewModel.cs b/Logic/ViewModels/MainViewModel.cs index 4e8b573..5827c5c 100644 --- a/Logic/ViewModels/MainViewModel.cs +++ b/Logic/ViewModels/MainViewModel.cs @@ -17,555 +17,60 @@ namespace LunaDraw.Logic.ViewModels { public class MainViewModel : ReactiveObject { - // Services - private readonly IToolStateManager toolStateManager; - private readonly ILayerStateManager layerStateManager; - private readonly ICanvasInputHandler canvasInputHandler; - private readonly IMessageBus messageBus; + // Dependencies + public IToolStateManager ToolStateManager { get; } + public ILayerStateManager LayerStateManager { get; } + public ICanvasInputHandler CanvasInputHandler { get; } public NavigationModel NavigationModel { get; } public SelectionManager SelectionManager { get; } + private readonly IMessageBus messageBus; - // Current State (Facade properties for View Binding) - public ObservableCollection Layers => layerStateManager.Layers; - public List AvailableTools => toolStateManager.AvailableTools; - public List AvailableBrushShapes => toolStateManager.AvailableBrushShapes; - - public Layer? CurrentLayer - { - get => layerStateManager.CurrentLayer; - set => layerStateManager.CurrentLayer = value; - } - - public IDrawingTool ActiveTool - { - get => toolStateManager.ActiveTool; - set => toolStateManager.ActiveTool = value; - } - - public SKColor StrokeColor - { - get => toolStateManager.StrokeColor; - set => toolStateManager.StrokeColor = value; - } - - public SKColor? FillColor - { - get => toolStateManager.FillColor; - set => toolStateManager.FillColor = value; - } - - public float StrokeWidth - { - get => toolStateManager.StrokeWidth; - set => toolStateManager.StrokeWidth = value; - } - - public byte Opacity - { - get => toolStateManager.Opacity; - set => toolStateManager.Opacity = value; - } - - public byte Flow - { - get => toolStateManager.Flow; - set => toolStateManager.Flow = value; - } - - public float Spacing - { - get => toolStateManager.Spacing; - set => toolStateManager.Spacing = value; - } - - public BrushShape CurrentBrushShape - { - get => toolStateManager.CurrentBrushShape; - set => toolStateManager.CurrentBrushShape = value; - } - - public bool IsGlowEnabled - { - get => toolStateManager.IsGlowEnabled; - set => toolStateManager.IsGlowEnabled = value; - } - - public SKColor GlowColor - { - get => toolStateManager.GlowColor; - set => toolStateManager.GlowColor = value; - } - - public float GlowRadius - { - get => toolStateManager.GlowRadius; - set => toolStateManager.GlowRadius = value; - } - - public bool IsRainbowEnabled - { - get => toolStateManager.IsRainbowEnabled; - set => toolStateManager.IsRainbowEnabled = value; - } - - public float ScatterRadius - { - get => toolStateManager.ScatterRadius; - set => toolStateManager.ScatterRadius = value; - } - - public float SizeJitter - { - get => toolStateManager.SizeJitter; - set => toolStateManager.SizeJitter = value; - } - - public float AngleJitter - { - get => toolStateManager.AngleJitter; - set => toolStateManager.AngleJitter = value; - } - - public float HueJitter - { - get => toolStateManager.HueJitter; - set => toolStateManager.HueJitter = value; - } - - public HistoryManager HistoryManager => layerStateManager.HistoryManager; - - // Selection State - public ReadOnlyObservableCollection SelectedElements => SelectionManager.Selected; - - // Internal Clipboard (Could be moved to a ClipboardService later) - private IEnumerable internalClipboard = new List(); + // Sub-ViewModels + public LayerPanelViewModel LayerPanelVM { get; } + public SelectionViewModel SelectionVM { get; } + public HistoryViewModel HistoryVM { get; } public SKRect CanvasSize { get; set; } - // OAPH properties for command states - private readonly ObservableAsPropertyHelper canDelete; - public bool CanDelete => canDelete.Value; - - private readonly ObservableAsPropertyHelper canGroup; - public bool CanGroup => canGroup.Value; - - private readonly ObservableAsPropertyHelper canUngroup; - public bool CanUngroup => canUngroup.Value; - - private readonly ObservableAsPropertyHelper canUndo; - public bool CanUndo => canUndo.Value; - - private readonly ObservableAsPropertyHelper canRedo; - public bool CanRedo => canRedo.Value; - - private readonly ObservableAsPropertyHelper canPaste; - public bool CanPaste => canPaste.Value; - - // Commands - public ReactiveCommand SelectToolCommand { 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 AddLayerCommand { get; } - public ReactiveCommand RemoveLayerCommand { get; } - public ReactiveCommand MoveLayerForwardCommand { get; } - public ReactiveCommand MoveLayerBackwardCommand { get; } - public ReactiveCommand MoveSelectionToLayerCommand { get; } - public ReactiveCommand ToggleLayerVisibilityCommand { get; } - public ReactiveCommand ToggleLayerLockCommand { get; } - public ReactiveCommand UndoCommand { get; } - public ReactiveCommand RedoCommand { get; } - public MainViewModel( IToolStateManager toolStateManager, ILayerStateManager layerStateManager, ICanvasInputHandler canvasInputHandler, NavigationModel navigationModel, SelectionManager selectionManager, - IMessageBus messageBus) - { - this.toolStateManager = toolStateManager; - this.layerStateManager = layerStateManager; - this.canvasInputHandler = canvasInputHandler; + IMessageBus messageBus, + LayerPanelViewModel layerPanelVM, + SelectionViewModel selectionVM, + HistoryViewModel historyVM) + { + ToolStateManager = toolStateManager; + LayerStateManager = layerStateManager; + CanvasInputHandler = canvasInputHandler; NavigationModel = navigationModel; SelectionManager = selectionManager; this.messageBus = messageBus; - // Subscribe to service property changes to notify View - this.toolStateManager.WhenAnyValue(x => x.ActiveTool).Subscribe(_ => this.RaisePropertyChanged(nameof(ActiveTool))); - this.toolStateManager.WhenAnyValue(x => x.StrokeColor).Subscribe(_ => this.RaisePropertyChanged(nameof(StrokeColor))); - this.toolStateManager.WhenAnyValue(x => x.FillColor).Subscribe(_ => this.RaisePropertyChanged(nameof(FillColor))); - this.toolStateManager.WhenAnyValue(x => x.StrokeWidth).Subscribe(_ => this.RaisePropertyChanged(nameof(StrokeWidth))); - this.toolStateManager.WhenAnyValue(x => x.Opacity).Subscribe(_ => this.RaisePropertyChanged(nameof(Opacity))); - this.toolStateManager.WhenAnyValue(x => x.Flow).Subscribe(_ => this.RaisePropertyChanged(nameof(Flow))); - this.toolStateManager.WhenAnyValue(x => x.Spacing).Subscribe(_ => this.RaisePropertyChanged(nameof(Spacing))); - this.toolStateManager.WhenAnyValue(x => x.CurrentBrushShape).Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentBrushShape))); - this.toolStateManager.WhenAnyValue(x => x.IsGlowEnabled).Subscribe(_ => this.RaisePropertyChanged(nameof(IsGlowEnabled))); - this.toolStateManager.WhenAnyValue(x => x.GlowColor).Subscribe(_ => this.RaisePropertyChanged(nameof(GlowColor))); - this.toolStateManager.WhenAnyValue(x => x.GlowRadius).Subscribe(_ => this.RaisePropertyChanged(nameof(GlowRadius))); - this.toolStateManager.WhenAnyValue(x => x.IsRainbowEnabled).Subscribe(_ => this.RaisePropertyChanged(nameof(IsRainbowEnabled))); - this.toolStateManager.WhenAnyValue(x => x.ScatterRadius).Subscribe(_ => this.RaisePropertyChanged(nameof(ScatterRadius))); - this.toolStateManager.WhenAnyValue(x => x.SizeJitter).Subscribe(_ => this.RaisePropertyChanged(nameof(SizeJitter))); - this.toolStateManager.WhenAnyValue(x => x.AngleJitter).Subscribe(_ => this.RaisePropertyChanged(nameof(AngleJitter))); - this.toolStateManager.WhenAnyValue(x => x.HueJitter).Subscribe(_ => this.RaisePropertyChanged(nameof(HueJitter))); - - // Layer State subscriptions - this.layerStateManager.WhenAnyValue(x => x.CurrentLayer).Subscribe(_ => this.RaisePropertyChanged(nameof(CurrentLayer))); - - // Invalidate all layers when selection changes to ensure proper cached/live transition - SelectionManager.SelectionChanged += (s, e) => - { - foreach (var layer in Layers) - { - layer.InvalidateCache(); - } - }; - - // Initialize OAPH properties for command states - canDelete = this.WhenAnyValue(x => x.SelectedElements.Count) - .Select(count => count > 0) - .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); - - canUndo = this.WhenAnyValue(x => x.HistoryManager.CanUndo) - .ToProperty(this, x => x.CanUndo); - - canRedo = this.WhenAnyValue(x => x.HistoryManager.CanRedo) - .ToProperty(this, x => x.CanRedo); - - canPaste = this.WhenAnyValue(x => x.internalClipboard) - .Select(clipboard => clipboard?.Any() == true) - .ToProperty(this, x => x.CanPaste); - - // Initialize commands - SelectToolCommand = ReactiveCommand.Create(tool => - { - ActiveTool = tool; // This sets it in the Service via property setter - }, outputScheduler: RxApp.MainThreadScheduler); - - DeleteSelectedCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer is null || !SelectedElements.Any()) return; - - var elementsToRemove = SelectedElements.ToList(); - foreach (var element in elementsToRemove) - { - CurrentLayer.Elements.Remove(element); - } - SelectionManager.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - }, this.WhenAnyValue(x => x.CanDelete), RxApp.MainThreadScheduler); - - GroupSelectedCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer is null || !SelectedElements.Any()) return; - - var elementsToGroup = SelectedElements.ToList(); - var group = new DrawableGroup(); - - foreach (var element in elementsToGroup) - { - CurrentLayer.Elements.Remove(element); - group.Children.Add(element); - } - CurrentLayer.Elements.Add(group); - SelectionManager.Clear(); - SelectionManager.Add(group); - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - }, this.WhenAnyValue(x => x.CanGroup), RxApp.MainThreadScheduler); - - UngroupSelectedCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer is null) return; - var group = SelectedElements.First() as DrawableGroup; - if (group != null) - { - CurrentLayer.Elements.Remove(group); - foreach (var child in group.Children) - { - CurrentLayer.Elements.Add(child); - } - SelectionManager.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - } - }, this.WhenAnyValue(x => x.CanUngroup), RxApp.MainThreadScheduler); - - CopyCommand = ReactiveCommand.Create(() => - { - internalClipboard = SelectedElements.Select(e => e.Clone()).ToList(); - }, this.WhenAnyValue(x => x.CanDelete), RxApp.MainThreadScheduler); - - CutCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer is null || !SelectedElements.Any()) return; - internalClipboard = SelectedElements.Select(e => e.Clone()).ToList(); - var elementsToRemove = SelectedElements.ToList(); - foreach (var element in elementsToRemove) - { - CurrentLayer.Elements.Remove(element); - } - SelectionManager.Clear(); - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - }, this.WhenAnyValue(x => x.CanDelete), RxApp.MainThreadScheduler); - - PasteCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer is null || !internalClipboard.Any()) return; - foreach (var element in internalClipboard) - { - var clone = element.Clone(); - clone.Translate(new SKPoint(10, 10)); // Offset pasted element - CurrentLayer.Elements.Add(clone); - } - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - }, this.WhenAnyValue(x => x.CanPaste), RxApp.MainThreadScheduler); - - AddLayerCommand = ReactiveCommand.Create(() => - { - this.layerStateManager.AddLayer(); - }, outputScheduler: RxApp.MainThreadScheduler); - - RemoveLayerCommand = ReactiveCommand.Create(layer => - { - this.layerStateManager.RemoveLayer(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - MoveLayerForwardCommand = ReactiveCommand.Create(layer => - { - this.layerStateManager.MoveLayerForward(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - MoveLayerBackwardCommand = ReactiveCommand.Create(layer => - { - this.layerStateManager.MoveLayerBackward(layer); - }, outputScheduler: RxApp.MainThreadScheduler); - - MoveSelectionToLayerCommand = ReactiveCommand.Create(targetLayer => - { - if (targetLayer == null || !SelectedElements.Any()) return; - this.layerStateManager.MoveElementsToLayer(SelectedElements, targetLayer); - // We do not clear selection here, so user can see where it went (if visible). - }, this.WhenAnyValue(x => x.CanDelete), RxApp.MainThreadScheduler); // Reusing CanDelete as proxy for "HasSelection" - - // New Commands for Send Backward / Bring Forward - // "Send Backward" -> Move down in the stack (decrease ZIndex) - var sendBackwardCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer == null || !SelectedElements.Any()) return; - - // Handling single selection for simplicity in this iteration - var selected = SelectedElements.First(); - var sortedElements = CurrentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); - int index = sortedElements.IndexOf(selected); - - if (index > 0) - { - // Swap with element below - var elementBelow = sortedElements[index - 1]; - sortedElements[index - 1] = selected; - sortedElements[index] = elementBelow; - - // Re-assign ZIndices - for (int i = 0; i < sortedElements.Count; i++) - { - sortedElements[i].ZIndex = i; - } - - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - } - - }, this.WhenAnyValue(x => x.CanDelete)); // Has Selection - - // "Bring Forward" -> Move up in the stack (increase ZIndex) - var bringForwardCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); - var sortedElements = CurrentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); - int index = sortedElements.IndexOf(selected); - - if (index < sortedElements.Count - 1) - { - // Swap with element above - var elementAbove = sortedElements[index + 1]; - sortedElements[index + 1] = selected; - sortedElements[index] = elementAbove; - - // Re-assign ZIndices - for (int i = 0; i < sortedElements.Count; i++) - { - sortedElements[i].ZIndex = i; - } - - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - } - - }, this.WhenAnyValue(x => x.CanDelete)); - - // Expose commands via properties if needed or add to a composite command? - // For now, I'll add them as public properties. - SendBackwardCommand = sendBackwardCommand; - BringForwardCommand = bringForwardCommand; - - // New Commands for Send Element to Back / Bring Element to Front - SendElementToBackCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); // Assuming single selection for simplicity - var elements = CurrentLayer.Elements.ToList(); // Get a mutable list - - if (elements.Remove(selected)) // Remove the selected element - { - elements.Insert(0, selected); // Insert it at the beginning - - // Re-assign ZIndices based on new order - for (int i = 0; i < elements.Count; i++) - { - elements[i].ZIndex = i; - } - // Update the observable collection (this will trigger UI update) - CurrentLayer.Elements.Clear(); - foreach (var el in elements) - { - CurrentLayer.Elements.Add(el); - } - - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - } - }, this.WhenAnyValue(x => x.CanDelete)); // CanExecute if there's a selection - - BringElementToFrontCommand = ReactiveCommand.Create(() => - { - if (CurrentLayer == null || !SelectedElements.Any()) return; - - var selected = SelectedElements.First(); // Assuming single selection for simplicity - var elements = CurrentLayer.Elements.ToList(); // Get a mutable list - - if (elements.Remove(selected)) // Remove the selected element - { - elements.Add(selected); // Add it to the end - - // Re-assign ZIndices based on new order - for (int i = 0; i < elements.Count; i++) - { - elements[i].ZIndex = i; - } - // Update the observable collection (this will trigger UI update) - CurrentLayer.Elements.Clear(); - foreach (var el in elements) - { - CurrentLayer.Elements.Add(el); - } - - messageBus.SendMessage(new CanvasInvalidateMessage()); - this.layerStateManager.SaveState(); - } - }, this.WhenAnyValue(x => x.CanDelete)); // CanExecute if there's a selection - - 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); - - UndoCommand = ReactiveCommand.Create(() => - { - var state = this.layerStateManager.HistoryManager.Undo(); - if (state != null) - { - RestoreState(state); - } - }, this.WhenAnyValue(x => x.CanUndo), RxApp.MainThreadScheduler); - - RedoCommand = ReactiveCommand.Create(() => - { - var state = this.layerStateManager.HistoryManager.Redo(); - if (state != null) - { - RestoreState(state); - } - }, this.WhenAnyValue(x => x.CanRedo), RxApp.MainThreadScheduler); + LayerPanelVM = layerPanelVM; + SelectionVM = selectionVM; + HistoryVM = historyVM; } - public ReactiveCommand SendBackwardCommand { get; } - public ReactiveCommand BringForwardCommand { get; } - public ReactiveCommand SendElementToBackCommand { get; } - public ReactiveCommand BringElementToFrontCommand { get; } - - public void SelectElementAt(SKPoint point) + // Facades for View/CodeBehind access + public ObservableCollection Layers => LayerStateManager.Layers; + + public Layer? CurrentLayer { - // Hit test elements across all layers from Top to Bottom - IDrawableElement? hitElement = null; - Layer? hitLayer = null; - - // Iterate layers from Top (Last) to Bottom (First) - foreach (var layer in Layers.Reverse()) - { - if (!layer.IsVisible || layer.IsLocked) 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; - hitLayer = layer; - break; // Found the top-most element - } - } + get => LayerStateManager.CurrentLayer; + set => LayerStateManager.CurrentLayer = value; + } - if (hitElement != null) - { - if (!SelectionManager.Contains(hitElement)) - { - SelectionManager.Clear(); - SelectionManager.Add(hitElement); - } - if (hitLayer != null) - { - CurrentLayer = hitLayer; - } - } - else - { - SelectionManager.Clear(); - } - messageBus.SendMessage(new CanvasInvalidateMessage()); + public IDrawingTool ActiveTool + { + get => ToolStateManager.ActiveTool; + set => ToolStateManager.ActiveTool = value; } + + public ReadOnlyObservableCollection SelectedElements => SelectionManager.Selected; public void ReorderLayer(Layer source, Layer target) { @@ -574,30 +79,43 @@ public void ReorderLayer(Layer source, Layer target) int newIndex = Layers.IndexOf(target); if (oldIndex >= 0 && newIndex >= 0) { - layerStateManager.MoveLayer(oldIndex, newIndex); + LayerStateManager.MoveLayer(oldIndex, newIndex); // Ensure the dragged layer stays selected CurrentLayer = source; } } - private void RestoreState(List state) - { - Layers.Clear(); - foreach (var layer in state) - { - Layers.Add(layer); - } - - // Try to find the previously selected layer by ID, or default to first - var currentLayerId = CurrentLayer?.Id; - CurrentLayer = Layers.FirstOrDefault(l => l.Id == currentLayerId) ?? Layers.FirstOrDefault(); - - messageBus.SendMessage(new CanvasInvalidateMessage()); - } - public void ProcessTouch(SKTouchEventArgs e) { - this.canvasInputHandler.ProcessTouch(e, CanvasSize); + CanvasInputHandler.ProcessTouch(e, CanvasSize); + } + + public ToolContext CreateToolContext() + { + return new ToolContext + { + CurrentLayer = LayerStateManager.CurrentLayer!, + StrokeColor = ToolStateManager.StrokeColor, + FillColor = ToolStateManager.FillColor, + StrokeWidth = ToolStateManager.StrokeWidth, + Opacity = ToolStateManager.Opacity, + Flow = ToolStateManager.Flow, + Spacing = ToolStateManager.Spacing, + BrushShape = ToolStateManager.CurrentBrushShape, + AllElements = LayerStateManager.Layers.SelectMany(l => l.Elements), + Layers = LayerStateManager.Layers, + SelectionManager = SelectionManager, + Scale = NavigationModel.TotalMatrix.ScaleX, + IsGlowEnabled = ToolStateManager.IsGlowEnabled, + GlowColor = ToolStateManager.GlowColor, + GlowRadius = ToolStateManager.GlowRadius, + IsRainbowEnabled = ToolStateManager.IsRainbowEnabled, + ScatterRadius = ToolStateManager.ScatterRadius, + SizeJitter = ToolStateManager.SizeJitter, + AngleJitter = ToolStateManager.AngleJitter, + HueJitter = ToolStateManager.HueJitter, + CanvasMatrix = NavigationModel.UserMatrix + }; } } -} \ No newline at end of file +} diff --git a/Logic/ViewModels/SelectionViewModel.cs b/Logic/ViewModels/SelectionViewModel.cs new file mode 100644 index 0000000..4939241 --- /dev/null +++ b/Logic/ViewModels/SelectionViewModel.cs @@ -0,0 +1,274 @@ +using System.Collections.ObjectModel; +using System.Reactive; +using System.Reactive.Linq; +using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Messages; +using LunaDraw.Logic.Models; +using ReactiveUI; +using SkiaSharp; + +namespace LunaDraw.Logic.ViewModels +{ + public class SelectionViewModel : ReactiveObject + { + private readonly SelectionManager _selectionManager; + private readonly ILayerStateManager _layerStateManager; + private readonly ClipboardManager _clipboardManager; + private readonly IMessageBus _messageBus; + + public SelectionViewModel( + SelectionManager selectionManager, + ILayerStateManager layerStateManager, + ClipboardManager clipboardManager, + IMessageBus messageBus) + { + _selectionManager = selectionManager; + _layerStateManager = layerStateManager; + _clipboardManager = clipboardManager; + _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); + } + + public ReadOnlyObservableCollection SelectedElements => _selectionManager.Selected; + + private readonly ObservableAsPropertyHelper canDelete; + public bool CanDelete => canDelete.Value; + + private readonly ObservableAsPropertyHelper canGroup; + public bool CanGroup => canGroup.Value; + + private readonly ObservableAsPropertyHelper canUngroup; + public bool CanUngroup => canUngroup.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; } + + private void MoveSelectionToLayer(Layer targetLayer) + { + if (targetLayer == null || !SelectedElements.Any()) return; + _layerStateManager.MoveElementsToLayer(SelectedElements, targetLayer); + } + + private void DeleteSelected() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; + + var elementsToRemove = SelectedElements.ToList(); + foreach (var element in elementsToRemove) + { + currentLayer.Elements.Remove(element); + } + _selectionManager.Clear(); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + + private void GroupSelected() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; + + var elementsToGroup = SelectedElements.ToList(); + var group = new DrawableGroup(); + + foreach (var element in elementsToGroup) + { + currentLayer.Elements.Remove(element); + group.Children.Add(element); + } + currentLayer.Elements.Add(group); + _selectionManager.Clear(); + _selectionManager.Add(group); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + + private void UngroupSelected() + { + var currentLayer = _layerStateManager.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); + } + _selectionManager.Clear(); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + } + + private void Copy() + { + _clipboardManager.Copy(SelectedElements); + } + + private void Cut() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer is null || !SelectedElements.Any()) return; + _clipboardManager.Copy(SelectedElements); + + var elementsToRemove = SelectedElements.ToList(); + foreach (var element in elementsToRemove) + { + currentLayer.Elements.Remove(element); + } + _selectionManager.Clear(); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + + private void Paste() + { + var currentLayer = _layerStateManager.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); + } + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + + private void SendBackward() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; + + var selected = SelectedElements.First(); + var sortedElements = currentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); + int index = sortedElements.IndexOf(selected); + + if (index > 0) + { + var elementBelow = sortedElements[index - 1]; + sortedElements[index - 1] = selected; + sortedElements[index] = elementBelow; + ReassignZIndices(sortedElements); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + } + + private void BringForward() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; + + var selected = SelectedElements.First(); + var sortedElements = currentLayer.Elements.OrderBy(e => e.ZIndex).ToList(); + int index = sortedElements.IndexOf(selected); + + if (index < sortedElements.Count - 1) + { + var elementAbove = sortedElements[index + 1]; + sortedElements[index + 1] = selected; + sortedElements[index] = elementAbove; + ReassignZIndices(sortedElements); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + } + + private void SendElementToBack() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; + + var selected = SelectedElements.First(); + var elements = currentLayer.Elements.ToList(); + + if (elements.Remove(selected)) + { + elements.Insert(0, selected); + // Clear and re-add to trigger ObservableCollection updates properly + // or just updating properties if Elements wasn't an ObservableCollection? + // It is ObservableCollection. + // Simpler: + currentLayer.Elements.Clear(); + foreach(var el in elements) currentLayer.Elements.Add(el); + + ReassignZIndices(elements); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + } + + private void BringElementToFront() + { + var currentLayer = _layerStateManager.CurrentLayer; + if (currentLayer == null || !SelectedElements.Any()) return; + + var selected = SelectedElements.First(); + var elements = currentLayer.Elements.ToList(); + + if (elements.Remove(selected)) + { + elements.Add(selected); + + currentLayer.Elements.Clear(); + foreach(var el in elements) currentLayer.Elements.Add(el); + + ReassignZIndices(elements); + _messageBus.SendMessage(new CanvasInvalidateMessage()); + _layerStateManager.SaveState(); + } + } + + private void ReassignZIndices(IList elements) + { + for (int i = 0; i < elements.Count; i++) + { + elements[i].ZIndex = i; + } + } + } +} diff --git a/Logic/ViewModels/ToolbarViewModel.cs b/Logic/ViewModels/ToolbarViewModel.cs index 16d505e..4ff5eb9 100644 --- a/Logic/ViewModels/ToolbarViewModel.cs +++ b/Logic/ViewModels/ToolbarViewModel.cs @@ -13,23 +13,24 @@ namespace LunaDraw.Logic.ViewModels { public class ToolbarViewModel : ReactiveObject { - private readonly MainViewModel mainViewModel; private readonly IToolStateManager toolStateManager; + private readonly SelectionViewModel selectionVM; + private readonly HistoryViewModel historyVM; private readonly IMessageBus messageBus; - // Forward properties from MainViewModel or ToolState + // Forward properties from ToolState public List AvailableTools => toolStateManager.AvailableTools; public List AvailableBrushShapes => toolStateManager.AvailableBrushShapes; - // Commands delegate to MainViewModel (for now, until commands are moved to services/viewmodels) - public ReactiveCommand SelectToolCommand => mainViewModel.SelectToolCommand; - public ReactiveCommand UndoCommand => mainViewModel.UndoCommand; - public ReactiveCommand RedoCommand => mainViewModel.RedoCommand; - public ReactiveCommand CopyCommand => mainViewModel.CopyCommand; - public ReactiveCommand PasteCommand => mainViewModel.PasteCommand; - public ReactiveCommand DeleteSelectedCommand => mainViewModel.DeleteSelectedCommand; - public ReactiveCommand GroupSelectedCommand => mainViewModel.GroupSelectedCommand; - public ReactiveCommand UngroupSelectedCommand => mainViewModel.UngroupSelectedCommand; + // 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; } @@ -125,70 +126,75 @@ public IDrawingTool LastActiveShapeTool set => this.RaiseAndSetIfChanged(ref lastActiveShapeTool, value); } - public ToolbarViewModel(MainViewModel mainViewModel, IToolStateManager toolStateManager, IMessageBus messageBus) + public ToolbarViewModel( + IToolStateManager toolStateManager, + SelectionViewModel selectionVM, + HistoryViewModel historyVM, + IMessageBus messageBus) { - this.mainViewModel = mainViewModel; this.toolStateManager = toolStateManager; + this.selectionVM = selectionVM; + this.historyVM = historyVM; this.messageBus = messageBus; - // Subscribe to ToolState changes via MainViewModel or directly? - // Using MainViewModel properties to maintain consistency if they are wrapped there, - // but better to use ToolState directly if possible. - // However, since MainViewModel exposes the same instances, it should be fine. - // Initialize ActiveTool and subscribe activeTool = this.toolStateManager.ActiveTool; - // We subscribe to the ViewModel's property which is already synced with the Service - // This ensures we are downstream of the MainViewModel's glue code if any exists. - this.mainViewModel.WhenAnyValue(x => x.ActiveTool) + this.toolStateManager.WhenAnyValue(x => x.ActiveTool) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(tool => ActiveTool = tool); - strokeColor = this.mainViewModel.WhenAnyValue(x => x.StrokeColor) - .ToProperty(this, x => x.StrokeColor, this.mainViewModel.StrokeColor); + // Create SelectToolCommand locally + SelectToolCommand = ReactiveCommand.Create(tool => + { + this.toolStateManager.ActiveTool = tool; + }, outputScheduler: RxApp.MainThreadScheduler); + + // Subscribe directly to IToolStateManager + strokeColor = this.toolStateManager.WhenAnyValue(x => x.StrokeColor) + .ToProperty(this, x => x.StrokeColor, this.toolStateManager.StrokeColor); - fillColor = this.mainViewModel.WhenAnyValue(x => x.FillColor) - .ToProperty(this, x => x.FillColor, this.mainViewModel.FillColor); + fillColor = this.toolStateManager.WhenAnyValue(x => x.FillColor) + .ToProperty(this, x => x.FillColor, this.toolStateManager.FillColor); - strokeWidth = this.mainViewModel.WhenAnyValue(x => x.StrokeWidth) - .ToProperty(this, x => x.StrokeWidth, this.mainViewModel.StrokeWidth); + strokeWidth = this.toolStateManager.WhenAnyValue(x => x.StrokeWidth) + .ToProperty(this, x => x.StrokeWidth, this.toolStateManager.StrokeWidth); - opacity = this.mainViewModel.WhenAnyValue(x => x.Opacity) - .ToProperty(this, x => x.Opacity, this.mainViewModel.Opacity); + opacity = this.toolStateManager.WhenAnyValue(x => x.Opacity) + .ToProperty(this, x => x.Opacity, this.toolStateManager.Opacity); - flow = this.mainViewModel.WhenAnyValue(x => x.Flow) - .ToProperty(this, x => x.Flow, this.mainViewModel.Flow); + flow = this.toolStateManager.WhenAnyValue(x => x.Flow) + .ToProperty(this, x => x.Flow, this.toolStateManager.Flow); - spacing = this.mainViewModel.WhenAnyValue(x => x.Spacing) - .ToProperty(this, x => x.Spacing, this.mainViewModel.Spacing); + spacing = this.toolStateManager.WhenAnyValue(x => x.Spacing) + .ToProperty(this, x => x.Spacing, this.toolStateManager.Spacing); - currentBrushShape = this.mainViewModel.WhenAnyValue(x => x.CurrentBrushShape) - .ToProperty(this, x => x.CurrentBrushShape, this.mainViewModel.CurrentBrushShape); + currentBrushShape = this.toolStateManager.WhenAnyValue(x => x.CurrentBrushShape) + .ToProperty(this, x => x.CurrentBrushShape, this.toolStateManager.CurrentBrushShape); - isGlowEnabled = this.mainViewModel.WhenAnyValue(x => x.IsGlowEnabled) - .ToProperty(this, x => x.IsGlowEnabled, initialValue: this.mainViewModel.IsGlowEnabled); + isGlowEnabled = this.toolStateManager.WhenAnyValue(x => x.IsGlowEnabled) + .ToProperty(this, x => x.IsGlowEnabled, initialValue: this.toolStateManager.IsGlowEnabled); - glowColor = this.mainViewModel.WhenAnyValue(x => x.GlowColor) - .ToProperty(this, x => x.GlowColor, initialValue: this.mainViewModel.GlowColor); + glowColor = this.toolStateManager.WhenAnyValue(x => x.GlowColor) + .ToProperty(this, x => x.GlowColor, initialValue: this.toolStateManager.GlowColor); - glowRadius = this.mainViewModel.WhenAnyValue(x => x.GlowRadius) - .ToProperty(this, x => x.GlowRadius, initialValue: this.mainViewModel.GlowRadius); + glowRadius = this.toolStateManager.WhenAnyValue(x => x.GlowRadius) + .ToProperty(this, x => x.GlowRadius, initialValue: this.toolStateManager.GlowRadius); - isRainbowEnabled = this.mainViewModel.WhenAnyValue(x => x.IsRainbowEnabled) - .ToProperty(this, x => x.IsRainbowEnabled, initialValue: this.mainViewModel.IsRainbowEnabled); + isRainbowEnabled = this.toolStateManager.WhenAnyValue(x => x.IsRainbowEnabled) + .ToProperty(this, x => x.IsRainbowEnabled, initialValue: this.toolStateManager.IsRainbowEnabled); - scatterRadius = this.mainViewModel.WhenAnyValue(x => x.ScatterRadius) - .ToProperty(this, x => x.ScatterRadius, initialValue: this.mainViewModel.ScatterRadius); + scatterRadius = this.toolStateManager.WhenAnyValue(x => x.ScatterRadius) + .ToProperty(this, x => x.ScatterRadius, initialValue: this.toolStateManager.ScatterRadius); - sizeJitter = this.mainViewModel.WhenAnyValue(x => x.SizeJitter) - .ToProperty(this, x => x.SizeJitter, initialValue: this.mainViewModel.SizeJitter); + sizeJitter = this.toolStateManager.WhenAnyValue(x => x.SizeJitter) + .ToProperty(this, x => x.SizeJitter, initialValue: this.toolStateManager.SizeJitter); - angleJitter = this.mainViewModel.WhenAnyValue(x => x.AngleJitter) - .ToProperty(this, x => x.AngleJitter, initialValue: this.mainViewModel.AngleJitter); + angleJitter = this.toolStateManager.WhenAnyValue(x => x.AngleJitter) + .ToProperty(this, x => x.AngleJitter, initialValue: this.toolStateManager.AngleJitter); - hueJitter = this.mainViewModel.WhenAnyValue(x => x.HueJitter) - .ToProperty(this, x => x.HueJitter, initialValue: this.mainViewModel.HueJitter); + hueJitter = this.toolStateManager.WhenAnyValue(x => x.HueJitter) + .ToProperty(this, x => x.HueJitter, initialValue: this.toolStateManager.HueJitter); isAnyFlyoutOpen = this.WhenAnyValue(x => x.IsSettingsOpen, x => x.IsShapesFlyoutOpen, x => x.IsBrushesFlyoutOpen) .Select(values => values.Item1 || values.Item2 || values.Item3) @@ -278,4 +284,4 @@ public ToolbarViewModel(MainViewModel mainViewModel, IToolStateManager toolState }); } } -} \ No newline at end of file +} diff --git a/MauiProgram.cs b/MauiProgram.cs index af1baee..240dead 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -41,8 +41,13 @@ public static MauiApp CreateMauiApp() // Register Logic Services builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Register ViewModels + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/Pages/MainPage.xaml.cs b/Pages/MainPage.xaml.cs index 53934c3..bcb943b 100644 --- a/Pages/MainPage.xaml.cs +++ b/Pages/MainPage.xaml.cs @@ -56,19 +56,19 @@ private void InitializeContextMenu() var arrangeSubMenu = new MenuFlyoutSubItem { Text = "Arrange" }; var sendToBackItem = new MenuFlyoutItem { Text = "Send To Back" }; - sendToBackItem.SetBinding(MenuItem.CommandProperty, new Binding("SendElementToBackCommand", source: viewModel)); + sendToBackItem.SetBinding(MenuItem.CommandProperty, new Binding("SelectionVM.SendElementToBackCommand", source: viewModel)); arrangeSubMenu.Add(sendToBackItem); var sendBackwardItem = new MenuFlyoutItem { Text = "Send Backward" }; - sendBackwardItem.SetBinding(MenuItem.CommandProperty, new Binding("SendBackwardCommand", source: viewModel)); + sendBackwardItem.SetBinding(MenuItem.CommandProperty, new Binding("SelectionVM.SendBackwardCommand", source: viewModel)); arrangeSubMenu.Add(sendBackwardItem); var bringForwardItem = new MenuFlyoutItem { Text = "Bring Forward" }; - bringForwardItem.SetBinding(MenuItem.CommandProperty, new Binding("BringForwardCommand", source: viewModel)); + bringForwardItem.SetBinding(MenuItem.CommandProperty, new Binding("SelectionVM.BringForwardCommand", source: viewModel)); arrangeSubMenu.Add(bringForwardItem); var sendToFrontItem = new MenuFlyoutItem { Text = "Send To Front" }; - sendToFrontItem.SetBinding(MenuItem.CommandProperty, new Binding("BringElementToFrontCommand", source: viewModel)); + sendToFrontItem.SetBinding(MenuItem.CommandProperty, new Binding("SelectionVM.BringElementToFrontCommand", source: viewModel)); arrangeSubMenu.Add(sendToFrontItem); canvasContextMenu.Add(arrangeSubMenu); @@ -90,7 +90,7 @@ private void UpdateContextMenu() moveToLayerSubMenu.Clear(); var addLayerItem = new MenuFlyoutItem { Text = "New Layer" }; - addLayerItem.SetBinding(MenuItem.CommandProperty, new Binding("AddLayerCommand", source: viewModel)); + addLayerItem.SetBinding(MenuItem.CommandProperty, new Binding("LayerPanelVM.AddLayerCommand", source: viewModel)); moveToLayerSubMenu.Add(addLayerItem); bool hasSelection = viewModel.SelectedElements.Any(); @@ -107,7 +107,7 @@ private void UpdateContextMenu() var item = new MenuFlyoutItem { Text = layer.Name, - Command = viewModel.MoveSelectionToLayerCommand, + Command = viewModel.SelectionVM.MoveSelectionToLayerCommand, CommandParameter = layer }; moveToLayerSubMenu.Add(item); @@ -209,7 +209,8 @@ private void OnCanvasViewPaintSurface(object? sender, SKPaintGLSurfaceEventArgs } } - viewModel.ActiveTool.DrawPreview(canvas, viewModel); + // UPDATED: Create tool context for preview + viewModel.ActiveTool.DrawPreview(canvas, viewModel.CreateToolContext()); canvas.Restore(); } @@ -238,4 +239,4 @@ private void CheckHideFlyouts() toolbarViewModel.IsBrushesFlyoutOpen = false; } } -} \ No newline at end of file +} diff --git a/tests/LunaDraw.Tests/LayerStateManagerTests.cs b/tests/LunaDraw.Tests/LayerStateManagerTests.cs index 6f067db..a47f2f0 100644 --- a/tests/LunaDraw.Tests/LayerStateManagerTests.cs +++ b/tests/LunaDraw.Tests/LayerStateManagerTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Reactive.Subjects; using FluentAssertions; +using LunaDraw.Logic.Managers; using LunaDraw.Logic.Messages; using LunaDraw.Logic.Models; using LunaDraw.Logic.Services; // Keep this for LayerStateManager diff --git a/tests/LunaDraw.Tests/MainViewModelTests.cs b/tests/LunaDraw.Tests/MainViewModelTests.cs new file mode 100644 index 0000000..9196d70 --- /dev/null +++ b/tests/LunaDraw.Tests/MainViewModelTests.cs @@ -0,0 +1,145 @@ +using System.Collections.ObjectModel; +using System.Reactive.Linq; +using FluentAssertions; +using LunaDraw.Logic.Managers; +using LunaDraw.Logic.Messages; +using LunaDraw.Logic.Models; +using LunaDraw.Logic.Services; +using LunaDraw.Logic.ViewModels; +using Moq; +using ReactiveUI; +using SkiaSharp; +using Xunit; + +namespace LunaDraw.Tests +{ + public class MainViewModelTests + { + private readonly Mock _toolStateManagerMock; + private readonly Mock _layerStateManagerMock; + private readonly Mock _canvasInputHandlerMock; + private readonly NavigationModel _navigationModel; + private readonly SelectionManager _selectionManager; + private readonly Mock _messageBusMock; + private readonly MainViewModel _viewModel; + private readonly ObservableCollection _layers; + private readonly HistoryManager _historyManager; + + // Sub-VMs + private readonly LayerPanelViewModel _layerPanelVM; + private readonly SelectionViewModel _selectionVM; + private readonly HistoryViewModel _historyVM; + + public MainViewModelTests() + { + _toolStateManagerMock = new Mock(); + _layerStateManagerMock = new Mock(); + _canvasInputHandlerMock = new Mock(); + _navigationModel = new NavigationModel(); + _selectionManager = new SelectionManager(); + _messageBusMock = new Mock(); + _layers = new ObservableCollection(); + _historyManager = new HistoryManager(); // Real HistoryManager for integration test + + _layerStateManagerMock.Setup(m => m.Layers).Returns(_layers); + _layerStateManagerMock.Setup(m => m.HistoryManager).Returns(_historyManager); + // Setup property change notifications for ToolStateManager + _toolStateManagerMock.As(); + + // Create Sub-VMs (we can test them in isolation, but here we test MainVM integration) + // Using real instances for VM logic + _layerPanelVM = new LayerPanelViewModel(_layerStateManagerMock.Object, _messageBusMock.Object); + _selectionVM = new SelectionViewModel(_selectionManager, _layerStateManagerMock.Object, new ClipboardManager(), _messageBusMock.Object); + _historyVM = new HistoryViewModel(_layerStateManagerMock.Object, _messageBusMock.Object); + + _viewModel = new MainViewModel( + _toolStateManagerMock.Object, + _layerStateManagerMock.Object, + _canvasInputHandlerMock.Object, + _navigationModel, + _selectionManager, + _messageBusMock.Object, + _layerPanelVM, + _selectionVM, + _historyVM + ); + } + + [Fact] + public void DeleteSelectedCommand_ShouldRemoveElements_WhenExecuted_ViaSelectionVM() + { + // Arrange + var layer = new Layer(); + var element = new DrawableRectangle(); + layer.Elements.Add(element); + _layers.Add(layer); + + _layerStateManagerMock.SetupGet(x => x.CurrentLayer).Returns(layer); + + _selectionManager.Add(element); + + // Act + // Executing command on SelectionVM which is exposed + _viewModel.SelectionVM.DeleteSelectedCommand.Execute().Subscribe(); + + // Assert + layer.Elements.Should().NotContain(element); + _selectionManager.Selected.Should().BeEmpty(); + _messageBusMock.Verify(x => x.SendMessage(It.IsAny()), Times.Once); + _layerStateManagerMock.Verify(x => x.SaveState(), Times.Once); + } + + [Fact] + public void GroupSelectedCommand_ShouldGroupElements_WhenExecuted_ViaSelectionVM() + { + // Arrange + var layer = new Layer(); + var element1 = new DrawableRectangle(); + var element2 = new DrawableRectangle(); + layer.Elements.Add(element1); + layer.Elements.Add(element2); + _layers.Add(layer); + + _layerStateManagerMock.SetupGet(x => x.CurrentLayer).Returns(layer); + + _selectionManager.Add(element1); + _selectionManager.Add(element2); + + // Act + _viewModel.SelectionVM.GroupSelectedCommand.Execute().Subscribe(); + + // Assert + layer.Elements.Should().NotContain(element1); + layer.Elements.Should().NotContain(element2); + layer.Elements.Should().ContainSingle(e => e is DrawableGroup); + _selectionManager.Selected.Should().HaveCount(1); + _selectionManager.Selected.First().Should().BeOfType(); + } + + [Fact] + public void PasteCommand_ShouldAddClonedElement_WhenClipboardHasItems_ViaSelectionVM() + { + // Arrange + var layer = new Layer(); + var element = new DrawableRectangle(); + layer.Elements.Add(element); + _layers.Add(layer); + _layerStateManagerMock.SetupGet(x => x.CurrentLayer).Returns(layer); + + _selectionManager.Add(element); + + // Copy first + _viewModel.SelectionVM.CopyCommand.Execute().Subscribe(); + + // Clear selection to verify paste adds new one + _selectionManager.Clear(); + + // Act + _viewModel.SelectionVM.PasteCommand.Execute().Subscribe(); + + // Assert + layer.Elements.Should().HaveCount(2); // Original + Paste + _messageBusMock.Verify(x => x.SendMessage(It.IsAny()), Times.AtLeastOnce); + } + } +} \ No newline at end of file diff --git a/tests/LunaDraw.Tests/SelectToolInteractionTests.cs b/tests/LunaDraw.Tests/SelectToolInteractionTests.cs index 440abc9..6c0e439 100644 --- a/tests/LunaDraw.Tests/SelectToolInteractionTests.cs +++ b/tests/LunaDraw.Tests/SelectToolInteractionTests.cs @@ -58,10 +58,14 @@ public void Dragging_MovesSelectedElement() var element = new MockDrawableElement(); var elements = new List { element }; var selectionManager = new SelectionManager(); + var layer = new Layer(); + layer.Elements.Add(element); + var context = new ToolContext { - CurrentLayer = new Layer(), + CurrentLayer = layer, AllElements = elements, + Layers = new List { layer }, SelectionManager = selectionManager, BrushShape = BrushShape.Circle() }; @@ -89,10 +93,14 @@ public void Resizing_BottomRight_ChangesTransformMatrix() // Pre-select the element so we can hit the handle selectionManager.Add(element); + var layer = new Layer(); + layer.Elements.Add(element); + var context = new ToolContext { - CurrentLayer = new Layer(), + CurrentLayer = layer, AllElements = elements, + Layers = new List { layer }, SelectionManager = selectionManager, BrushShape = BrushShape.Circle() }; diff --git a/tests/LunaDraw.Tests/SelectToolTests.cs b/tests/LunaDraw.Tests/SelectToolTests.cs index 129f145..b8e4c2f 100644 --- a/tests/LunaDraw.Tests/SelectToolTests.cs +++ b/tests/LunaDraw.Tests/SelectToolTests.cs @@ -31,10 +31,14 @@ public void OnTouchPressed_UpdatesSelection_Correctly(bool initiallySelected, bo selectionManager.Add(element); } + var layer = new Layer(); + layer.Elements.Add(element); + var context = new ToolContext { - CurrentLayer = new Layer(), + CurrentLayer = layer, AllElements = elements, + Layers = new List { layer }, SelectionManager = selectionManager, BrushShape = BrushShape.Circle() }; From b3ce2053d01872b0f22ee61e3a3dc48dd1db9999 Mon Sep 17 00:00:00 2001 From: Jeff H Date: Sat, 6 Dec 2025 23:35:00 -0500 Subject: [PATCH 2/6] add image feature --- .clinerules/.clinerules.md | 1 + Components/ToolbarView.xaml | 10 ++ Logic/Models/DrawableImage.cs | 168 +++++++++++++++++++++++++++ Logic/Models/DrawablePath.cs | 15 ++- Logic/Tools/EraserBrushTool.cs | 63 +++++++--- Logic/ViewModels/ToolbarViewModel.cs | 43 ++++++- 6 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 Logic/Models/DrawableImage.cs diff --git a/.clinerules/.clinerules.md b/.clinerules/.clinerules.md index d1fcc09..4acbca4 100644 --- a/.clinerules/.clinerules.md +++ b/.clinerules/.clinerules.md @@ -16,6 +16,7 @@ - We should NEVER keep legacy or duplicate code. A refactor or update means refactoring to a clean state and not leaving behind bloat and obselete code. - No underscores - No regions +- DO NOT use abbreviations for anything. Variable names or otherwise ## Testing diff --git a/Components/ToolbarView.xaml b/Components/ToolbarView.xaml index 67c3ad6..2acc71c 100644 --- a/Components/ToolbarView.xaml +++ b/Components/ToolbarView.xaml @@ -120,6 +120,16 @@