Skip to content

DynamoDS/DYN-10149: As a Dynamo user, I want the home and end keys to navigate to notes as well.#17136

Closed
eamiri wants to merge 3 commits into
masterfrom
10149-home-end-navigate-notes
Closed

DynamoDS/DYN-10149: As a Dynamo user, I want the home and end keys to navigate to notes as well.#17136
eamiri wants to merge 3 commits into
masterfrom
10149-home-end-navigate-notes

Conversation

@eamiri

@eamiri eamiri commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Closes https://autodesk.atlassian.net/browse/DYN-10149

h1. Extend Home/End Navigation to Notes — Implementation Plan

h2. Overview

Extend the existing {{Home}}/{{End}} keyboard shortcuts (DYN-9637) so that, in addition to leftmost/rightmost nodes, they also navigate to orphaned notes and to notes-only groups. The change is localized to the {{"Home And End key press events in Canvas"}} region in {{DynamoViewModel.cs}}, plus new tests in {{CoreUITests.cs}}. No public API, XAML, or resource changes are required.

h2. Current State Analysis

h3. Key Discoveries

  • Existing logic only iterates {{CurrentSpaceViewModel.Nodes}} — {{DynamoViewModel.cs:4542-4567}}.
  • The selection model accepts {{NodeModel}}, {{NoteModel}}, and {{AnnotationModel}} interchangeably (all {{ISelectable}} via {{ModelBase}}), so the extension is purely about which items go into {{DynamoSelection.Instance.Selection}} before {{FitViewCommand.Execute(true)}} runs.
  • {{tolerance = 2}} constant at {{DynamoViewModel.cs:79-80}} is reusable.
  • {{NoteViewModel}} has {{Left}}/{{Top}} but no {{Width}} — use {{noteVM.Model.Width}} for the right-edge calculation.
  • {{AnnotationViewModel}} already has {{Left}}/{{Width}} for bounding-box math, suitable for notes-only group ranking.
  • Orphan check: {{!CurrentSpaceViewModel.Annotations.Any(a => a.AnnotationModel.ContainsModel(noteModel))}} (uses {{AnnotationModelExtensions.ContainsModel}}).
  • Notes-only group check: {{!annotationVM.Nodes.OfType().Any() && annotationVM.Nodes.OfType().Any()}}.
  • Zero existing test coverage for {{GoToLeftMostNodeCommand}} / {{GoToRightMostNodeCommand}} — verified via grep across {{test/}}.

h2. Desired End State

  • Pressing {{Home}} selects the leftmost workspace item — be it a node, an orphaned note, or a notes-only group — plus any group containing a winning node, and fits the view to that selection. Selection is cleared afterward.
  • Pressing {{End}} does the symmetric operation on the rightmost item, using right edge ({{X + Width}}).
  • Notes that live inside a group also containing nodes still ride along with their parent group (the existing path); they do not contribute to leftmost/rightmost ranking on their own.
  • A workspace with only notes still responds to {{Home}}/{{End}}.
  • {{IsHomeAndEndKeyEnabled}} continues to disable both shortcuts when anything is selected.
  • New NUnit tests pass and explicitly cover both legacy and new behavior.

Verification: Run {{dotnet test test/DynamoCoreWpfTests/DynamoCoreWpfTests.csproj --filter "NameGoToLeftMost|NameGoToRightMost"}} — all new tests pass; manual smoke test in Dynamo Sandbox with a graph that has nodes, orphaned notes, and notes-only groups confirms the camera centers on the expected element.

h2. What We're NOT Doing

  • Renaming {{GoToLeftMostNode}} / {{GoToRightMostNode}} (and their {{Command}} properties) to {{GoToLeftMostElement}} / {{GoToRightMostElement}}. They are listed in {{PublicAPI.Unshipped.txt}} so a rename is technically free, but the ticket does not request it and a rename would touch unrelated files. Defer.
  • Including notes that live inside a mixed (node + note) group in the leftmost/rightmost ranking. The ticket scope is {{notes in groups alone}} and {{orphaned notes}}; pinned-label notes are intentionally excluded so the camera does not jump off the node they label.
  • Adding any new public API.
  • Changing keybindings, key gestures, or XAML.
  • Splitting {{GoToLeftMostNodeCommand}} / {{GoToRightMostNodeCommand}} into separate node and note commands. One command per direction, smarter target selection.
  • Backward-compatibility shims or feature flags. The behavior change is purely additive and the methods are {{internal}}.

h2. Implementation Approach

Refactor {{FitCanvasToSelectedNodes}} into a shape that accepts a heterogeneous candidate list (each candidate knows its left edge, right edge, and which {{ISelectable}}s to add — possibly including a containing group). {{GoToLeftMostNode}} / {{GoToRightMostNode}} build that list from three sources (nodes, orphaned notes, notes-only groups), pick the winners by {{X}} or {{X + Width}} within {{tolerance}}, and delegate selection + {{FitView}} to the shared helper. Relax the guard from {{Nodes.Count > 0}} to "candidate list is non-empty".

The simplest concrete shape: introduce a private nested struct/record (e.g. {{HomeEndCandidate}}) capturing {{Left}}, {{Right}}, and an {{IEnumerable Selection}}. This stays internal, requires no allocations beyond the existing per-call ones, and keeps the diff readable.


h3. TASK 1: Extend Home/End logic in {{DynamoViewModel}} [HIGH PRIORITY]

Status: DONE
Milestone: {{Home}}/{{End}} shortcuts in a running sandbox correctly target orphaned notes and notes-only groups in addition to nodes; no regression in existing node-only behavior.

  • In {{DynamoViewModel.cs}} inside the {{"Home And End key press events in Canvas"}} region, introduce a private nested type {{HomeEndCandidate}} with fields {{double Left}}, {{double Right}}, {{IReadOnlyList Models}} (the items to add to {{DynamoSelection}}).
  • Add a private helper {{IEnumerable BuildHomeEndCandidates()}} that yields:
    ** One candidate per {{NodeViewModel}} in {{CurrentSpaceViewModel.Nodes}}: {{Left = nvm.X}}, {{Right = nvm.X + nvm.ActualWidth}}, {{Models = [nvm.NodeModel, ...any AnnotationModel that contains nvm.NodeModel]}}.
    ** One candidate per orphaned {{NoteViewModel}} (where no {{AnnotationModel}} in the workspace contains its {{Model}}): {{Left = noteVM.Model.X}}, {{Right = noteVM.Model.X + noteVM.Model.Width}}, {{Models = [noteVM.Model]}}.
    ** One candidate per notes-only {{AnnotationViewModel}} (where {{.Nodes.OfType().Any() == false}} AND {{.Nodes.OfType().Any()}}): {{Left = avm.Left}}, {{Right = avm.Left + avm.Width}}, {{Models = [avm.AnnotationModel]}}.
  • Replace {{FitCanvasToSelectedNodes(List)}} with {{FitCanvasToSelectedCandidates(IEnumerable)}}: union all {{Models}} from the candidates into {{DynamoSelection.Instance.Selection}}, invoke {{FitViewCommand.Execute(true)}}, then {{ClearSelection()}}.
  • Update {{GoToLeftMostNode}} to: build candidates → if empty, return → compute {{minX = candidates.Min(c => c.Left)}} → take candidates within {{tolerance}} ({{c.Left <= minX + tolerance}}) → call {{FitCanvasToSelectedCandidates}}.
  • Update {{GoToRightMostNode}} symmetrically using {{c.Right}} and {{maxRight - tolerance}}.
  • Leave {{IsHomeAndEndKeyEnabled}}, {{CanGoToLeftMostNode}}, {{CanGoToRightMostNode}} unchanged — the {{!HasSelection}} gate already accounts for notes and groups.
  • Confirm via build that no other caller of the now-renamed {{FitCanvasToSelectedNodes}} exists ({{grep}} should show zero hits — it is private).

Validation:

  • {{dotnet restore src/DynamoCore.sln --runtime=win-x64 -p:Configuration=Release -p:DotNet=net10.0 && msbuild src/DynamoCore.sln /p:Configuration=Release}} succeeds.
  • {{msbuild src/Dynamo.All.sln /p:Configuration=Release}} succeeds.
  • No new entries in {{PublicAPI.Unshipped.txt}}.

Requirements from spec:

  • {{Home}} and {{End}} work with notes in groups alone.
  • {{Home}} and {{End}} work with orphaned notes.
  • DYN-9637 behavior preserved for nodes and node-containing groups.

Files to Modify:

  • {{src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs}} — replace the region body at lines 4523-4568 with the candidate-based implementation.

Implementation Details:

  • Use {{using Dynamo.Graph.Annotations;}} to bring in {{AnnotationModelExtensions.ContainsModel}} (file: {{src/DynamoCore/Graph/Annotations/AnnotationModelExtensions.cs}}). The {{using}} is almost certainly already present; verify before adding.
  • Candidate construction order does not matter — we re-rank by {{Left}} / {{Right}} anyway.
  • Be careful with the {{nodes-only}} containing-group lookup: a node candidate's {{Models}} collection may include zero, one, or several {{AnnotationModel}}s (a node can in theory be in one group; nested groups carry their own membership). Mirror the existing line {{CurrentSpaceViewModel.Annotations.Where(a => a.Nodes.Any(n => nodeSet.Contains(n)))}} semantics — but per-candidate rather than per-batch.
  • Do not change the {{private readonly int tolerance = 2;}} constant.

h3. TASK 2: Add NUnit coverage for legacy and new behavior [HIGH PRIORITY]

Status: DONE
Milestone: All Home/End behavior is exercised by automated tests; future regressions are caught locally and in CI.

  • Open {{test/DynamoCoreWpfTests/CoreUITests.cs}}. Add a new {{#region Home and End Key Navigation}} after the existing {{#region Fit to View}} (line 287). Tests use the same {{[Test, Apartment(ApartmentState.STA)]}} + {{[Category("DynamoUI")]}} attributes already established in the file.
  • Test 1 — {{WhenWorkspaceIsEmpty_HomeAndEndCommands_DoNothing}}: Empty {{CurrentSpaceViewModel}}. Capture {{Zoom}}/{{X}}/{{Y}}, invoke both commands, assert unchanged.
  • Test 2 — {{WhenWorkspaceHasOnlyNodes_HomeSelectsLeftmostNodeAndFits}}: Place two {{CodeBlockNodeModel}}s at distinct X coordinates, invoke {{GoToLeftMostNodeCommand.Execute(null)}}, assert {{FitView}} ran (zoom/position changed in a way consistent with the leftmost node's bounds).
  • Test 3 — {{WhenWorkspaceHasOnlyNodes_EndSelectsRightmostNodeAndFits}}: Symmetric of Test 2 using {{GoToRightMostNodeCommand}}.
  • Test 4 — {{WhenWorkspaceContainsOrphanedNoteLeftOfNodes_HomeTargetsTheNote}}: One node and one orphaned {{NoteModel}} placed further left than the node. Invoke {{GoToLeftMostNodeCommand}}, assert the view fits to the note's bounds.
  • Test 5 — {{WhenWorkspaceContainsNoteInsideNodeContainingGroup_HomeIgnoresTheNote}}: A group with one node and one note; the note's {{X}} is smaller than the node's {{X}} but the group's overall {{X}} matches the node. Confirm the camera lands on the group/node, not the note.
  • Test 6 — {{WhenLeftmostItemIsNotesOnlyGroup_HomeTargetsTheGroup}}: Place a group whose contents are exclusively notes to the left of any node, invoke {{GoToLeftMostNodeCommand}}, assert the view fits to the group's bounds.
  • Test 7 — {{WhenWorkspaceHasOnlyNotes_HomeAndEndStillNavigate}}: No nodes at all, only orphaned notes. Assert both commands fit the view (i.e. the relaxed guard works).
  • Test 8 — {{WhenSelectionIsNonEmpty_CanGoToLeftMostNodeReturnsFalse}}: Select a {{NoteModel}}, assert {{CanGoToLeftMostNode(null)}} returns {{false}}. Repeat for {{NodeModel}} and {{AnnotationModel}}. Confirms {{IsHomeAndEndKeyEnabled}} gate covers notes/groups.
  • Test 9 (End equivalents): Add the matching {{End}}-direction tests for Tests 4, 6, 7 to keep coverage symmetric.
  • All tests must follow {{WhenConditionThenExpectedBehavior}} naming and use Arrange-Act-Assert. One behavior per test.

Validation:

  • {{dotnet test test/DynamoCoreWpfTests/DynamoCoreWpfTests.csproj --filter "NameHomeAndEnd|NameGoToLeftMost|Name~GoToRightMost"}} — all new tests pass; no existing tests regress.
  • {{dotnet test test/DynamoCoreWpfTests/DynamoCoreWpfTests.csproj --filter "Category=DynamoUI"}} for full UI category sanity check.

Requirements from spec:

  • Coverage for orphaned notes and notes-only groups (new behavior).
  • Coverage retroactively added for DYN-9637 node-only behavior.
  • Gate test confirms {{IsHomeAndEndKeyEnabled}} rejects every selectable type.

Files to Modify:

  • {{test/DynamoCoreWpfTests/CoreUITests.cs}} — add the new {{#region Home and End Key Navigation}} block with the tests above.

Implementation Details:

  • For asserting "the view moved to roughly here", reuse the pattern in {{FitViewWithNoNodes}}/{{CanFitView}} (line 289+): capture {{workspaceVM.Zoom}}/{{X}}/{{Y}} before, compare after. Where exact coordinates are unstable across screen DPI, assert directionality (e.g. {{Assert.AreNotEqual(initZoom, workspaceVM.Zoom)}} plus a check that the resulting visible rectangle contains the target item's bounds).
  • Create notes via the existing {{WorkspaceModel.AddNote(...)}} / {{DynamoModel.ExecuteCommand(new DynamoModel.CreateNoteCommand(...))}} flow — search {{CoreUITests.cs}} or {{WorkspaceTests.cs}} for an existing example before inventing a new helper.
  • Create groups via {{DynamoModel.AddAnnotation(...)}} after selecting the target models. The pattern is established in {{AnnotationModelTests}} in the core test project; replicate the smallest possible setup here.
  • Always {{DynamoSelection.Instance.ClearSelection()}} in test teardown / after assertions to avoid bleed between tests in the same fixture.

h2. Testing Strategy

h3. Unit Tests

  • New tests in {{test/DynamoCoreWpfTests/CoreUITests.cs}} (see TASK 2). Exercise all branches of {{BuildHomeEndCandidates}} (node, orphan note, notes-only group) and both {{Home}} and {{End}} directions.
  • Verify the {{IsHomeAndEndKeyEnabled}} gate disables both shortcuts when any of {{NodeModel}}, {{NoteModel}}, or {{AnnotationModel}} is selected.
  • Empty-workspace, notes-only-workspace, nodes-only-workspace, and mixed-workspace permutations.

h3. Integration Tests

  • Run {{dotnet test src/DynamoCore.sln}} to confirm no regressions in core graph logic from the candidate-set refactor.
  • Run {{dotnet test test/DynamoCoreWpfTests}} to confirm UI tests pass end-to-end.

h3. Manual Testing Steps

Launch {{DynamoSandbox}} ({{msbuild src/Dynamo.All.sln /p:Configuration=Release}} then run the sandbox exe).

Open an empty graph. Press {{Home}}, then {{End}} — view should not change, no exception.

Add one node, three orphaned notes (one to the far left, one to the far right, one centered). Press {{Home}} — view fits to the leftmost note. Press {{End}} — view fits to the rightmost note.

Add a group containing only two notes, place it to the left of all nodes. Press {{Home}} — view fits to that group.

Add a group containing one node and one note where the note's X < the node's X but the group's overall bounding box matches the node. Press {{Home}} — view fits to the group/node, not the note.

Select any item (node, note, or group) and press {{Home}}/{{End}} — the camera should not move (gate disables the shortcut).

Verify symmetry: repeat steps 3-6 with {{End}} in place of {{Home}}.

h2. Performance Considerations

  • {{BuildHomeEndCandidates}} is O(N) in workspace items per keypress. {{Home}}/{{End}} are user-driven events at human cadence (not redrawn per frame), so the additional traversal of {{Notes}} and {{Annotations}} adds no measurable cost even for large graphs (≤ thousands of items). The existing {{Nodes}} traversal already runs on every press.
  • The orphan check costs O(notes × annotations) in the worst case. For pathological graphs this is still well under millisecond-scale and runs only on keypress; no optimization needed. If profiling later shows this on a hot path, precompute a {{HashSet}} of group-contained notes once per call and use it as the orphan filter.

h2. Migration Notes

None. The change is purely additive: existing behavior for nodes is preserved exactly, and no persisted state, file format, or public API surface is affected. No upgrade path needed for existing graphs.

@eamiri eamiri added the kiln Created by kiln label Jun 3, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the ticket for this pull request: https://jira.autodesk.com/browse/DYN-10149

…roups

Refactors the Home/End candidate construction in DynamoViewModel so that
orphaned NoteModels and notes-only AnnotationModels participate in the
leftmost/rightmost ranking alongside nodes. Notes inside mixed
(node+note) groups are intentionally excluded so they do not steer the
camera away from the group bounding box that already covers them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment on lines +4558 to +4564
foreach (var avm in annotations)
{
if (avm.AnnotationModel.ContainsModel(node))
{
models.Add(avm.AnnotationModel);
}
}
{
foreach (var candidate in candidates)
{
foreach (var model in candidate.Models)
Add 12 NUnit tests in CoreUITests.cs covering the extended
Home/End shortcut behavior:

- Legacy node-only navigation (Home/End select leftmost/rightmost
  node and fit the view)
- Orphaned notes participating in the leftmost/rightmost ranking
- Notes-only groups participating in the ranking
- Notes inside a node-containing group ride along with the group
  and do not contribute their own X to the ranking
- Notes-only workspace still responds to Home/End
- Empty workspace is a no-op
- IsHomeAndEndKeyEnabled gate disables both shortcuts for any
  selectable type (NodeModel, NoteModel, AnnotationModel)

Selection contents during the command are verified via a
NotifyCollectionChanged hook on DynamoSelection, which is robust
to the implementation clearing the selection at the end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@eamiri eamiri marked this pull request as ready for review June 3, 2026 06:56
@eamiri eamiri added the pr_ready PR is ready for review label Jun 3, 2026
@sonarqubecloud

sonarqubecloud Bot commented Jun 3, 2026

Copy link
Copy Markdown

@eamiri eamiri closed this Jun 8, 2026
@eamiri eamiri deleted the 10149-home-end-navigate-notes branch June 8, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kiln Created by kiln pr_ready PR is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant