Fix Issue #26867: Proper Export Parts List Keyboard Navagation#32990
Fix Issue #26867: Proper Export Parts List Keyboard Navagation#32990sshadid-code wants to merge 2 commits intomusescore:masterfrom
Conversation
Implemented navigation handling for ExportScoresListView, allowing up and down navigation through the list and focus management without unexpected behavior of looping after 20 items without manual scrolling
📝 WalkthroughWalkthroughAdded explicit navigation handling to checkbox delegates in the export scores list. The checkbox now sets Changes
Estimated code review effort🎯 Medium | ⏱️ ~30 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: d81967cb-cce1-473e-b3a7-1db674586929
📒 Files selected for processing (1)
src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml
| navigation.onNavigationEvent: function(event) { | ||
| let targetIndex = delegateItem.index | ||
|
|
||
| switch (event.type) { | ||
| case NavigationEvent.Up: | ||
| if (delegateItem.index === 0) { | ||
| event.accepted = true | ||
| return | ||
| } | ||
| targetIndex = delegateItem.index - 1 | ||
| break | ||
|
|
||
| case NavigationEvent.Down: | ||
| if (delegateItem.index === listView.count - 1) { | ||
| event.accepted = true | ||
| return | ||
| } | ||
| targetIndex = delegateItem.index + 1 | ||
| break | ||
|
|
||
| default: | ||
| return | ||
| } | ||
|
|
||
| listView.positionViewAtIndex(targetIndex, ListView.Contain) | ||
|
|
||
| Qt.callLater(function() { | ||
| let item = listView.itemAtIndex(targetIndex) | ||
| if (item) { | ||
| item.requestActiveFocus() | ||
| } | ||
| }) | ||
|
|
||
| event.accepted = true | ||
| } | ||
|
|
||
| Connections { | ||
| target: checkBox.navigation | ||
|
|
||
| function onActiveChanged() { | ||
| if (target.active) { | ||
| listView.positionViewAtIndex(index, ListView.Contain) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Please add a UI/QML regression test for this keyboard path.
Given this now owns custom boundary and focus behavior, add automated coverage for: (1) repeated Down to last item without wrap, (2) repeated Up to first item without wrap, and (3) deep-list traversal beyond initial viewport.
Based on learnings: In the MuseScore codebase, vtests are intended for src/engraving only; for .qml UI changes, use UI/QML-appropriate checks instead of vtests.
| } | ||
| }) | ||
|
|
||
| event.accepted = true |
There was a problem hiding this comment.
Lines 115-128 should not be needed. When the event is not accepted, the navigation system will move the focus to the checkbox above/below (what lines 121-126 do). This will trigger onActiveChanged and inside it the list view will be scrolled (i.e. what line 119 does will be done by line 136). Then targetIndex won't be needed either. P.S. Nope, I am wrong. Upon further testing I see that my suggestion does not work as I expected. Moving down works well but when you start going up, at some point it skips items and goes directly to the first item (the main score). I just noticed that the same buggy behavior exists with the Shortcuts list in Preferences that I gave you as an example. :) I'll investigate this when I can. Disregard my suggestion. P.S. ListView acts weirdly with regard to the instantiation of delegates outside of the visible area.
Your code does work by first scrolling the list view and then navigating. Do we need lines 121-128? If the list view gets scrolled (line 119) and the event is not accepted, then the navigation system should correctly move the focus. Also positionViewAtIndex ends up called twice (once in onNavigationEvent and again in onActiveChanged) but at least everything works. The second call won't do anything and should be fast. onActiveChanged can be removed to avoid the double call, but it does help with the other navigation keys not handled by the code such as PgUp, PgDn, Home and End.
There was a problem hiding this comment.
Your code does work by first scrolling the list view and then navigating. Do we need lines 121-128? If the list view gets scrolled (line 119) and the event is not accepted, then the navigation system should correctly move the focus. Also positionViewAtIndex ends up called twice (once in onNavigationEvent and again in onActiveChanged) but at least everything works. The second call won't do anything and should be fast. onActiveChanged can be removed to avoid the double call, but it does help with the other navigation keys not handled by the code such as PgUp, PgDn, Home and End.
From what I can tell navigation.onActiveChanged (which I updated to no longer use Connection) could be removed if either
A. the alternate navigation options such as left arrow, right arrow, PgUp, and PgDn could be disabled
B. the alternate navigation options could be included in the navigation.onNavigationEvent code
I've attempted to take approach B but have been unable to find what events these alternate navigation options are tied to. This may very well be due to my lack of understanding of the MuseScore codebase and/or my lack of familiarity with QML. Without knowing how to implement option A or B, navigation.onActiveChanged remains required.
There was a problem hiding this comment.
That's fine. I concluded my comment saying onActiveChanged should stay because it helps with the navigation with PgUp, PgDn, Home and End.
| } | ||
|
|
||
| Connections { | ||
| target: checkBox.navigation |
There was a problem hiding this comment.
Since this code is inside the checkbox, there is no need for Connections. Just like with the previous handler for navigation.onNavigationEvent above, here we can simply write
navigation.onActiveChanged: {
. . .
}
Then target.active will become navigation.active. You can take a look at the other navigation.onActiveChanged instances in the code.
I also suggest changing index to delegateItem.Index. index works but is more work for Qt to find it since it will first look for it in the checkbox and then up in the parents. Qualifying it with the ID of the object it belongs to eliminates this (and makes it clearer).
|
Nice work, @sshadid-code ! I've left a couple of suggestions for simplification. :) |
Thank @krasko78. I'll aim to implement and test these changes by the end of the day (potentially by tomorrow depending on my schedule). If there are any other changes you think I should implement for the PR to be accepted please let me know. |
|
I edited my first comment because I discovered I was wrong. PR will have to be reviewed by Eism so you may wait for his feedback. |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml (1)
132-136: 🧹 Nitpick | 🔵 TrivialQualify
indexwith the delegate id.In
onActiveChanged,indexis resolved via scope lookup (checkbox → parents) and findsdelegateItem.index(therequired property int indexon line 65). Qualifying it asdelegateItem.indexmatches how it's used elsewhere in this file (lines 86, 92, 96, 100, 104, 108, 112) and is slightly cheaper/clearer.♻️ Proposed change
navigation.onActiveChanged: { if (navigation.active) { - listView.positionViewAtIndex(index, ListView.Contain) + listView.positionViewAtIndex(delegateItem.index, ListView.Contain) } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml` around lines 132 - 136, The handler navigation.onActiveChanged uses an unqualified index which resolves by scope lookup to delegateItem.index; change the usage to explicitly use delegateItem.index (as done elsewhere) when calling listView.positionViewAtIndex to make the reference clear and slightly more efficient—update the call in navigation.onActiveChanged to pass delegateItem.index to listView.positionViewAtIndex.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml`:
- Around line 122-127: The current key-handling unconditionally marks the event
accepted even if listView.itemAtIndex(targetIndex) returns null, causing silent
focus loss; update the handler so inside the Qt.callLater callback you check if
item is non-null and then call item.requestActiveFocus() and set event.accepted
= true, but if item is null do not set event.accepted (allowing the framework to
handle the keystroke) and optionally call
listView.positionViewAtIndex(targetIndex, ListView.Contain) to attempt
materializing the delegate for the next pass; reference listView, itemAtIndex,
positionViewAtIndex and the existing event.accepted usage when making the
change.
---
Duplicate comments:
In `@src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml`:
- Around line 132-136: The handler navigation.onActiveChanged uses an
unqualified index which resolves by scope lookup to delegateItem.index; change
the usage to explicitly use delegateItem.index (as done elsewhere) when calling
listView.positionViewAtIndex to make the reference clear and slightly more
efficient—update the call in navigation.onActiveChanged to pass
delegateItem.index to listView.positionViewAtIndex.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 36a7d588-1bb1-4cd3-ad21-051bd2f1837a
📒 Files selected for processing (1)
src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml
| Qt.callLater(function() { | ||
| let item = listView.itemAtIndex(targetIndex) | ||
| if (item) { | ||
| item.requestActiveFocus() | ||
| } | ||
| }) |
There was a problem hiding this comment.
Silent focus loss if the target delegate is not yet instantiated.
positionViewAtIndex(..., ListView.Contain) should materialize the target delegate, but itemAtIndex can still return null for delegates outside the cache/viewport in edge cases (large lists, rapid key repeat, cacheBuffer pressure). Since event.accepted = true is set unconditionally on line 129, a null item means the keystroke is silently swallowed — focus stays on the current checkbox and the user sees no response.
Consider falling back to letting the framework handle the event when the item isn't available yet, e.g.:
🛡️ Suggested guard
- listView.positionViewAtIndex(targetIndex, ListView.Contain)
-
- Qt.callLater(function() {
- let item = listView.itemAtIndex(targetIndex)
- if (item) {
- item.requestActiveFocus()
- }
- })
-
- event.accepted = true
+ listView.positionViewAtIndex(targetIndex, ListView.Contain)
+
+ Qt.callLater(function() {
+ let item = listView.itemAtIndex(targetIndex)
+ if (item) {
+ item.requestActiveFocus()
+ }
+ })
+
+ // Accept so the default navigation doesn't also move focus;
+ // the callLater above will land focus on the (now-visible) target.
+ event.accepted = trueWorth confirming with rapid Down key-repeat on a very long parts list (200+) that focus never gets stuck.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml`
around lines 122 - 127, The current key-handling unconditionally marks the event
accepted even if listView.itemAtIndex(targetIndex) returns null, causing silent
focus loss; update the handler so inside the Qt.callLater callback you check if
item is non-null and then call item.requestActiveFocus() and set event.accepted
= true, but if item is null do not set event.accepted (allowing the framework to
handle the keystroke) and optionally call
listView.positionViewAtIndex(targetIndex, ListView.Contain) to attempt
materializing the delegate for the next pass; reference listView, itemAtIndex,
positionViewAtIndex and the existing event.accepted usage when making the
change.
Implemented navigation handling for ExportScoresListView, allowing up and down navigation through the list and focus management without unexpected behavior of looping after 20 items without manual scrolling
Resolves: #26867
Explicitly set navigation.column to 0 to ensure proper handling of the left and right arrow keys. The addition of Connections handles automatically scrolling the export menu while navigating with the arrow keys. This alone completely handles using arrow key navigation down the list of export options. Added navigation.onNavigationEvent to disallow navigating down from the final option and up from the first option. Otherwise such inputs would cause improper navigation behavior. This code also handles a bug where attempting to navigate back up the list would skip to the first option after 11 up inputs past the currently displayed top of the list.
No unit test was made in place of using extensive manual testing. This was due to time constraints and the small number of inputs possible in the export menu's part selection.