diff --git a/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs b/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs index 74739a6f347..2c774411030 100644 --- a/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs +++ b/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs @@ -23,6 +23,7 @@ using Dynamo.Graph.Connectors; using Dynamo.Graph.Nodes; using Dynamo.Graph.Nodes.CustomNodes; +using Dynamo.Graph.Notes; using Dynamo.Graph.Workspaces; using Dynamo.Interfaces; using Dynamo.Logging; @@ -4521,6 +4522,21 @@ internal bool CanDumpNodeIconData(object obj) } #region "Home And End key press events in Canvas" + + private readonly struct HomeEndCandidate + { + public HomeEndCandidate(double left, double right, IReadOnlyList models) + { + Left = left; + Right = right; + Models = models; + } + + public double Left { get; } + public double Right { get; } + public IReadOnlyList Models { get; } + } + /// /// Enable the shortcut key events when there is no selection. /// @@ -4530,23 +4546,68 @@ private bool IsHomeAndEndKeyEnabled() return BackgroundPreviewViewModel != null && !CurrentSpaceViewModel.HasSelection; } - private void FitCanvasToSelectedNodes(List nodes) + + private IEnumerable BuildHomeEndCandidates() { - var nodeSet = new HashSet(nodes.Select(nvm => nvm.NodeModel)); - var groups = CurrentSpaceViewModel.Annotations.Where(a => a.Nodes.Any(n => nodeSet.Contains(n))); - nodes.ForEach((ele) => DynamoSelection.Instance.Selection.Add(ele.NodeModel)); - groups.ToList().ForEach((grp) => DynamoSelection.Instance.Selection.Add(grp.AnnotationModel)); + var annotations = CurrentSpaceViewModel.Annotations; + + foreach (var nvm in CurrentSpaceViewModel.Nodes) + { + var node = nvm.NodeModel; + var models = new List { node }; + foreach (var avm in annotations) + { + if (avm.AnnotationModel.ContainsModel(node)) + { + models.Add(avm.AnnotationModel); + } + } + yield return new HomeEndCandidate(nvm.X, nvm.X + nvm.ActualWidth, models); + } + + foreach (var noteVM in CurrentSpaceViewModel.Notes) + { + var noteModel = noteVM.Model; + if (annotations.Any(a => a.AnnotationModel.ContainsModel(noteModel))) + { + continue; + } + yield return new HomeEndCandidate(noteVM.Left, noteVM.Left + noteModel.Width, new ISelectable[] { noteModel }); + } + + foreach (var avm in annotations) + { + var hasNodes = avm.Nodes.OfType().Any(); + var hasNotes = avm.Nodes.OfType().Any(); + if (hasNodes || !hasNotes) + { + continue; + } + yield return new HomeEndCandidate(avm.Left, avm.Left + avm.Width, new ISelectable[] { avm.AnnotationModel }); + } + } + + private void FitCanvasToSelectedCandidates(IEnumerable candidates) + { + foreach (var candidate in candidates) + { + foreach (var model in candidate.Models) + { + DynamoSelection.Instance.Selection.Add(model); + } + } FitViewCommand.Execute(true); DynamoSelection.Instance.ClearSelection(); } + internal void GoToLeftMostNode(object parameter) { - if (CurrentSpaceViewModel.Nodes.Count > 0) - { - double minX = CurrentSpaceViewModel.Nodes.Min(x => x.X); - var nodes = CurrentSpaceViewModel.Nodes.Where(x => x.X <= minX + tolerance).ToList(); - FitCanvasToSelectedNodes(nodes); - } + var candidates = BuildHomeEndCandidates().ToList(); + if (candidates.Count == 0) return; + + double minX = candidates.Min(c => c.Left); + var winners = candidates.Where(c => c.Left <= minX + tolerance); + FitCanvasToSelectedCandidates(winners); } internal bool CanGoToLeftMostNode(object obj) { @@ -4554,12 +4615,12 @@ internal bool CanGoToLeftMostNode(object obj) } internal void GoToRightMostNode(object parameter) { - if (CurrentSpaceViewModel.Nodes.Count > 0) - { - double maxX = CurrentSpaceViewModel.Nodes.Max(x => x.X + x.ActualWidth); - var nodes = CurrentSpaceViewModel.Nodes.Where(x => x.X + x.ActualWidth >= maxX - tolerance).ToList(); - FitCanvasToSelectedNodes(nodes); - } + var candidates = BuildHomeEndCandidates().ToList(); + if (candidates.Count == 0) return; + + double maxRight = candidates.Max(c => c.Right); + var winners = candidates.Where(c => c.Right >= maxRight - tolerance); + FitCanvasToSelectedCandidates(winners); } internal bool CanGoToRightMostNode(object obj) { diff --git a/test/DynamoCoreWpfTests/CoreUITests.cs b/test/DynamoCoreWpfTests/CoreUITests.cs index f3d076e0763..e2f2b0d2ab0 100644 --- a/test/DynamoCoreWpfTests/CoreUITests.cs +++ b/test/DynamoCoreWpfTests/CoreUITests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.IO; using System.Linq; using System.Threading; @@ -12,9 +13,12 @@ using Dynamo.Configuration; using Dynamo.Controls; using Dynamo.Extensions; +using Dynamo.Graph; +using Dynamo.Graph.Annotations; using Dynamo.Graph.Connectors; using Dynamo.Graph.Nodes; using Dynamo.Graph.Nodes.ZeroTouch; +using Dynamo.Graph.Notes; using Dynamo.Graph.Workspaces; using Dynamo.Linting.Interfaces; using Dynamo.Linting.Rules; @@ -439,6 +443,280 @@ private void CreateNodeOnCurrentWorkspace() #endregion + #region Home and End Key Navigation + + private DoubleInput AddNodeAt(double x, double y) + { + var node = new DoubleInput { X = x, Y = y }; + ViewModel.Model.CurrentWorkspace.AddAndRegisterNode(node, false); + return node; + } + + private NoteModel AddNoteAt(double x, double y, double width = 100, double height = 50) + { + var note = ViewModel.Model.CurrentWorkspace.AddNote( + false, x, y, "n", Guid.NewGuid()); + note.Width = width; + note.Height = height; + return note; + } + + private AnnotationModel GroupAround(params ModelBase[] models) + { + DynamoSelection.Instance.ClearSelection(); + foreach (var m in models) + { + DynamoSelection.Instance.Selection.Add(m); + } + ViewModel.AddAnnotationCommand.Execute(null); + var annotation = ViewModel.Model.CurrentWorkspace.Annotations.Last(); + DynamoSelection.Instance.ClearSelection(); + return annotation; + } + + private List CaptureSelectionDuring(Action action) + { + var captured = new List(); + NotifyCollectionChangedEventHandler handler = (s, e) => + { + if (e.NewItems == null) return; + foreach (ISelectable item in e.NewItems) + { + captured.Add(item); + } + }; + DynamoSelection.Instance.Selection.CollectionChanged += handler; + try + { + action(); + } + finally + { + DynamoSelection.Instance.Selection.CollectionChanged -= handler; + } + return captured; + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceIsEmpty_HomeAndEndCommands_DoNothing() + { + // Arrange + var workspaceVM = ViewModel.CurrentSpaceViewModel; + var initZoom = workspaceVM.Zoom; + var initX = workspaceVM.X; + var initY = workspaceVM.Y; + + // Act + Assert (does not throw, view unchanged) + Assert.DoesNotThrow(() => ViewModel.GoToLeftMostNodeCommand.Execute(null)); + Assert.DoesNotThrow(() => ViewModel.GoToRightMostNodeCommand.Execute(null)); + + Assert.AreEqual(initZoom, workspaceVM.Zoom); + Assert.AreEqual(initX, workspaceVM.X); + Assert.AreEqual(initY, workspaceVM.Y); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceHasOnlyNodes_HomeSelectsLeftmostNodeAndFits() + { + // Arrange + var leftNode = AddNodeAt(100, 100); + var rightNode = AddNodeAt(500, 100); + var workspaceVM = ViewModel.CurrentSpaceViewModel; + var initZoom = workspaceVM.Zoom; + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToLeftMostNodeCommand.Execute(null)); + + // Assert + CollectionAssert.Contains(selected, leftNode); + CollectionAssert.DoesNotContain(selected, rightNode); + Assert.AreNotEqual(initZoom, workspaceVM.Zoom); + Assert.IsEmpty(DynamoSelection.Instance.Selection); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceHasOnlyNodes_EndSelectsRightmostNodeAndFits() + { + // Arrange + var leftNode = AddNodeAt(100, 100); + var rightNode = AddNodeAt(500, 100); + var workspaceVM = ViewModel.CurrentSpaceViewModel; + var initZoom = workspaceVM.Zoom; + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToRightMostNodeCommand.Execute(null)); + + // Assert + CollectionAssert.Contains(selected, rightNode); + CollectionAssert.DoesNotContain(selected, leftNode); + Assert.AreNotEqual(initZoom, workspaceVM.Zoom); + Assert.IsEmpty(DynamoSelection.Instance.Selection); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceContainsOrphanedNoteLeftOfNodes_HomeTargetsTheNote() + { + // Arrange + var node = AddNodeAt(500, 100); + var orphanNote = AddNoteAt(50, 100); + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToLeftMostNodeCommand.Execute(null)); + + // Assert + CollectionAssert.Contains(selected, orphanNote); + CollectionAssert.DoesNotContain(selected, node); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceContainsOrphanedNoteRightOfNodes_EndTargetsTheNote() + { + // Arrange + var node = AddNodeAt(50, 100); + var orphanNote = AddNoteAt(1000, 100); + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToRightMostNodeCommand.Execute(null)); + + // Assert + CollectionAssert.Contains(selected, orphanNote); + CollectionAssert.DoesNotContain(selected, node); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceContainsNoteInsideNodeContainingGroup_HomeIgnoresTheNote() + { + // Arrange: a group containing both a node and a note where note.X < node.X. + // The note rides along with its parent group via the node-candidate path; + // it should not contribute its own (smaller) X to the leftmost ranking. + var node = AddNodeAt(500, 100); + var noteInsideGroup = AddNoteAt(50, 100); + var group = GroupAround(node, noteInsideGroup); + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToLeftMostNodeCommand.Execute(null)); + + // Assert: the note is not the target. The group accompanies the node. + CollectionAssert.Contains(selected, node); + CollectionAssert.Contains(selected, group); + CollectionAssert.DoesNotContain(selected, noteInsideGroup); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenLeftmostItemIsNotesOnlyGroup_HomeTargetsTheGroup() + { + // Arrange + var node = AddNodeAt(800, 100); + var noteA = AddNoteAt(50, 100); + var noteB = AddNoteAt(120, 200); + var notesOnlyGroup = GroupAround(noteA, noteB); + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToLeftMostNodeCommand.Execute(null)); + + // Assert: the notes-only group is the leftmost candidate. + CollectionAssert.Contains(selected, notesOnlyGroup); + CollectionAssert.DoesNotContain(selected, node); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenRightmostItemIsNotesOnlyGroup_EndTargetsTheGroup() + { + // Arrange + var node = AddNodeAt(50, 100); + var noteA = AddNoteAt(800, 100); + var noteB = AddNoteAt(900, 200); + var notesOnlyGroup = GroupAround(noteA, noteB); + + // Act + var selected = CaptureSelectionDuring( + () => ViewModel.GoToRightMostNodeCommand.Execute(null)); + + // Assert + CollectionAssert.Contains(selected, notesOnlyGroup); + CollectionAssert.DoesNotContain(selected, node); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenWorkspaceHasOnlyNotes_HomeAndEndStillNavigate() + { + // Arrange + var leftNote = AddNoteAt(50, 100); + var rightNote = AddNoteAt(800, 100); + var workspaceVM = ViewModel.CurrentSpaceViewModel; + var initZoom = workspaceVM.Zoom; + + // Act + Assert: Home targets the left note + var leftSelected = CaptureSelectionDuring( + () => ViewModel.GoToLeftMostNodeCommand.Execute(null)); + CollectionAssert.Contains(leftSelected, leftNote); + CollectionAssert.DoesNotContain(leftSelected, rightNote); + Assert.AreNotEqual(initZoom, workspaceVM.Zoom); + + // Act + Assert: End targets the right note + var rightSelected = CaptureSelectionDuring( + () => ViewModel.GoToRightMostNodeCommand.Execute(null)); + CollectionAssert.Contains(rightSelected, rightNote); + CollectionAssert.DoesNotContain(rightSelected, leftNote); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenSelectionContainsNode_CanGoToLeftMostNodeReturnsFalse() + { + var node = AddNodeAt(100, 100); + DynamoSelection.Instance.Selection.Add(node); + + Assert.IsFalse(ViewModel.GoToLeftMostNodeCommand.CanExecute(null)); + Assert.IsFalse(ViewModel.GoToRightMostNodeCommand.CanExecute(null)); + + DynamoSelection.Instance.ClearSelection(); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenSelectionContainsNote_CanGoToLeftMostNodeReturnsFalse() + { + var note = AddNoteAt(100, 100); + DynamoSelection.Instance.Selection.Add(note); + + Assert.IsFalse(ViewModel.GoToLeftMostNodeCommand.CanExecute(null)); + Assert.IsFalse(ViewModel.GoToRightMostNodeCommand.CanExecute(null)); + + DynamoSelection.Instance.ClearSelection(); + } + + [Test, Apartment(ApartmentState.STA)] + [Category("DynamoUI")] + public void WhenSelectionContainsAnnotation_CanGoToLeftMostNodeReturnsFalse() + { + var node = AddNodeAt(100, 100); + var group = GroupAround(node); + DynamoSelection.Instance.Selection.Add(group); + + Assert.IsFalse(ViewModel.GoToLeftMostNodeCommand.CanExecute(null)); + Assert.IsFalse(ViewModel.GoToRightMostNodeCommand.CanExecute(null)); + + DynamoSelection.Instance.ClearSelection(); + } + + #endregion + [Test, Apartment(ApartmentState.STA)] [Category("DynamoUI")] public void PreferenceSetting_RenderPrecision()