Skip to content

Fix Issue #26867: Proper Export Parts List Keyboard Navagation#32990

Open
sshadid-code wants to merge 2 commits intomusescore:masterfrom
sshadid-code:export-menu-fix
Open

Fix Issue #26867: Proper Export Parts List Keyboard Navagation#32990
sshadid-code wants to merge 2 commits intomusescore:masterfrom
sshadid-code:export-menu-fix

Conversation

@sshadid-code
Copy link
Copy Markdown

@sshadid-code sshadid-code commented Apr 12, 2026

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.

  • I signed the CLA
  • The title of the PR describes the problem it addresses
  • Each commit's message describes its purpose and effects, and references the issue it resolves
  • If changes are extensive, there is a sequence of easily reviewable commits
  • The code in the PR follows the coding rules
  • There are no unnecessary changes
  • The code compiles and runs on my machine, preferably after each commit individually
  • I created a unit test or vtest to verify the changes I made (if applicable)

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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 12, 2026

📝 Walkthrough

Walkthrough

Added explicit navigation handling to checkbox delegates in the export scores list. The checkbox now sets navigation.column: 0, handles Up/Down navigation events by computing a clamped target index, repositions the ListView to that index, defers focus to the destination delegate via Qt.callLater, and marks navigation events as accepted. A handler also repositions the ListView when a delegate's navigation active state changes.

Changes

Cohort / File(s) Summary
Export Dialog Navigation Enhancement
src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml
Added navigation.column: 0 to checkbox delegates; implemented checkBox.navigation.onNavigationEvent to handle Up/Down keys by computing a targetIndex, clamping bounds, calling listView.positionViewAtIndex(targetIndex), deferring listView.itemAtIndex(targetIndex).requestActiveFocus() with Qt.callLater, and marking the event accepted. Added checkBox.navigation.onActiveChanged to reposition the ListView when the delegate becomes navigation-active.

Estimated code review effort

🎯 Medium | ⏱️ ~30 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title contains a typo ('Navagation' instead of 'Navigation') and refers to 'Export Parts List' while the changes are in ExportScoresListView, making it slightly misleading about the scope, though it does address the core issue. Correct the typo to 'Navigation' and ensure the title accurately reflects whether this is about 'Export Parts List' or 'Export Scores List' to match the actual file and component being modified.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description comprehensively covers the changes, linked issue, and technical approach; most checklist items are marked complete, though unit testing was appropriately deferred due to constraints.
Linked Issues check ✅ Passed The code changes directly address issue #26867 by implementing proper keyboard navigation handling to prevent list cycling and enable access to all parts without unexpected wrapping.
Out of Scope Changes check ✅ Passed All changes are scoped to the ExportScoresListView navigation behavior specified in issue #26867; no extraneous modifications are present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d81967cb-cce1-473e-b3a7-1db674586929

📥 Commits

Reviewing files that changed from the base of the PR and between 6f59207 and a0c9514.

📒 Files selected for processing (1)
  • src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml

Comment on lines +95 to +139
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)
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

@mathesoncalum mathesoncalum requested a review from Eism April 13, 2026 08:50
}
})

event.accepted = true
Copy link
Copy Markdown
Contributor

@krasko78 krasko78 Apr 13, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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).

@krasko78
Copy link
Copy Markdown
Contributor

Nice work, @sshadid-code ! I've left a couple of suggestions for simplification. :)
I think the title of the PR is not very accurate since it does not deal with a menu.

@sshadid-code
Copy link
Copy Markdown
Author

Nice work, @sshadid-code ! I've left a couple of suggestions for simplification. :) I think the title of the PR is not very accurate since it does not deal with a menu.

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.

@krasko78
Copy link
Copy Markdown
Contributor

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.

@sshadid-code sshadid-code changed the title Fix Issue #26867: Proper Export Menu Keyboard Navagation Fix Issue #26867: Proper Export Parts List Keyboard Navagation Apr 18, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml (1)

132-136: 🧹 Nitpick | 🔵 Trivial

Qualify index with the delegate id.

In onActiveChanged, index is resolved via scope lookup (checkbox → parents) and finds delegateItem.index (the required property int index on line 65). Qualifying it as delegateItem.index matches 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

📥 Commits

Reviewing files that changed from the base of the PR and between a0c9514 and d98fbe5.

📒 Files selected for processing (1)
  • src/project/qml/MuseScore/Project/internal/Export/ExportScoresListView.qml

Comment on lines +122 to +127
Qt.callLater(function() {
let item = listView.itemAtIndex(targetIndex)
if (item) {
item.requestActiveFocus()
}
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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 = true

Worth 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Maximum of 20 instruments available with screen reader in the export dialogue box

3 participants