fix(settings): harden migration and accessibility

Apply interface scaling through Qt at startup, complete keyboard navigation, move account migration into setup and progress modals, and defer Subsonic sync while the server scans.

cx
This commit is contained in:
vicrodh committed 2026-09-02 14:33:08 -06:00
1 parent 015ca48994
commit 472706f84e
38 files changed
+2962 -275

No files matched your search

@@ -92,16 +92,35 @@ Column {
spacing: 6
Rectangle {
id: swatch
width: 56
height: 32
radius: theme.radiusSm
border.width: cell.open ? 2 : 1
border.color: cell.open ? theme.accent : theme.borderSubtle
border.width: cell.open || swatch.activeFocus ? 2 : 1
border.color: cell.open || swatch.activeFocus
? theme.accent : theme.borderSubtle
color: root.tokenColor(cell.tokenKey)
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: cell.label
Accessible.onPressAction: swatch.activate()
function activate() {
root.openToken = cell.open ? "" : cell.tokenKey
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
swatch.activate()
event.accepted = true
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.openToken = cell.open ? "" : cell.tokenKey
onPressed: swatch.forceActiveFocus()
onClicked: swatch.activate()
}
}
Text {
+146 -3
View File
@@ -69,6 +69,9 @@ Item {
property bool showManual: false
readonly property int openSeq: doc.openSeq || 0
onVisibleChanged: if (visible) {
Qt.callLater(function () { closeX.forceActiveFocus() })
}
onOpenSeqChanged: {
root.step = 0
root.welcomeConfirmed = false
@@ -77,6 +80,13 @@ Item {
root.reviewRestartDone = false
root.showManual = false
manualInput.text = ""
if (root.visible)
Qt.callLater(function () { closeX.forceActiveFocus() })
}
Keys.onEscapePressed: function (event) {
QbzDacWizard.close()
event.accepted = true
}
// Step labels for the header rail (index = step).
@@ -116,12 +126,34 @@ Item {
width: parent ? parent.width : 0
height: 28
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.CheckBox
Accessible.name: label
Accessible.checked: checked
Accessible.onToggleAction: toggled()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
checkRow.toggled()
event.accepted = true
}
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "transparent"
border.width: checkRow.activeFocus ? 2 : 0
border.color: theme.accent
}
QbzCheckbox {
id: box
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
checked: checkRow.checked
enabled: false
// DISPLAY-ONLY. The row MouseArea below is declared last, so it
// sits on top and owns every click — exactly as the reference
// shadows QbzCheckbox's internal toggle. No `onToggled` here on
@@ -142,6 +174,7 @@ Item {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: checkRow.forceActiveFocus()
onClicked: checkRow.toggled()
}
}
@@ -231,6 +264,26 @@ Item {
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
activeFocusOnTab: root.visible
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Close", QbzSession.trRev)
Accessible.onPressAction: QbzDacWizard.close()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
QbzDacWizard.close()
event.accepted = true
}
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "transparent"
border.width: closeX.activeFocus ? 2 : 0
border.color: theme.accent
}
QbzIcon {
anchors.centerIn: parent
width: 17
@@ -243,6 +296,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: closeX.forceActiveFocus()
onClicked: QbzDacWizard.close()
}
}
@@ -535,9 +589,33 @@ Item {
// Escape-hatch toggle (only when enumeration found devices;
// otherwise the manual block shows unconditionally).
Item {
id: manualEntryToggle
visible: root.doc.hasEnumeration === true
width: parent.width
height: visible ? 22 : 0
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: manualToggle.text
Accessible.onPressAction: root.showManual = !root.showManual
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root.showManual = !root.showManual
event.accepted = true
}
}
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
width: manualToggle.implicitWidth
height: parent.height
radius: theme.radiusSm
color: "transparent"
border.width: manualEntryToggle.activeFocus ? 2 : 0
border.color: theme.accent
}
Text {
id: manualToggle
anchors.verticalCenter: parent.verticalCenter
@@ -553,6 +631,7 @@ Item {
anchors.bottom: parent.bottom
width: manualToggle.implicitWidth
cursorShape: Qt.PointingHandCursor
onPressed: manualEntryToggle.forceActiveFocus()
onClicked: root.showManual = !root.showManual
}
}
@@ -667,12 +746,26 @@ Item {
spacing: 8
Rectangle {
id: configHeader
width: parent.width
height: 40
radius: theme.radiusSm
color: cfgHead.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
border.color: activeFocus ? theme.accent : theme.borderSubtle
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: cfgCol.modelData.name || ""
Accessible.onPressAction: QbzDacWizard.toggleConfig(cfgCol.index)
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
QbzDacWizard.toggleConfig(cfgCol.index)
event.accepted = true
}
}
QbzIcon {
id: cfgChevron
@@ -701,6 +794,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: configHeader.forceActiveFocus()
onClicked: QbzDacWizard.toggleConfig(cfgCol.index)
}
}
@@ -776,6 +870,24 @@ Item {
radius: theme.radiusSm
color: (trackTa.containsMouse && testStep.playing)
? theme.surfaceHover : "transparent"
activeFocusOnTab: testStep.playing
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: trackRow.modelData
Accessible.onPressAction: {
if (testStep.playing)
QbzDacWizard.testPlayIndex(trackRow.index)
}
Keys.onPressed: function (event) {
if (testStep.playing && !event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
QbzDacWizard.testPlayIndex(trackRow.index)
event.accepted = true
}
}
// Fixed glyph slot (width AND height) so the
// ♪→▶ hover swap can never reflow the row.
@@ -809,6 +921,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: testStep.playing ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: if (testStep.playing) trackRow.forceActiveFocus()
onClicked: {
if (testStep.playing)
QbzDacWizard.testPlayIndex(trackRow.index)
@@ -841,12 +954,26 @@ Item {
// playing (the 4 test tracks or the user's own), which
// is why they call the PLAYER, not the wizard.
Rectangle {
id: prevButton
visible: testStep.playing
width: visible ? 34 : 0
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
border.color: activeFocus ? theme.accent : theme.borderSubtle
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Previous", QbzSession.trRev)
Accessible.onPressAction: QbzPlayer.previous()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
QbzPlayer.previous()
event.accepted = true
}
}
color: prevTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
@@ -860,16 +987,31 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: prevButton.forceActiveFocus()
onClicked: QbzPlayer.previous()
}
}
Rectangle {
id: nextButton
visible: testStep.playing
width: visible ? 34 : 0
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
border.color: activeFocus ? theme.accent : theme.borderSubtle
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Next", QbzSession.trRev)
Accessible.onPressAction: QbzPlayer.next()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
QbzPlayer.next()
event.accepted = true
}
}
color: nextTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
@@ -883,6 +1025,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: nextButton.forceActiveFocus()
onClicked: QbzPlayer.next()
}
}
+51 -10
View File
@@ -120,8 +120,29 @@ Column {
visible: (section.rows || []).length > 0
Item {
id: sectionHeader
width: parent.width
height: 28
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: section.title
Accessible.onPressAction: section.open = !section.open
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
section.open = !section.open
event.accepted = true
}
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "transparent"
border.width: sectionHeader.activeFocus ? 2 : 0
border.color: theme.accent
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: section.title
@@ -140,6 +161,7 @@ Column {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: sectionHeader.forceActiveFocus()
onClicked: section.open = !section.open
}
}
@@ -180,8 +202,35 @@ Column {
// ------------------------------------------------------------- header
Item {
id: panelHeader
width: parent.width
height: 44
activeFocusOnTab: visible && enabled
Accessible.role: Accessible.Button
Accessible.name: root.t("Diagnostics")
Accessible.onPressAction: panelHeader.activate()
function activate() {
root.expanded = !root.expanded
QbzShell.diagSetOpen(root.expanded)
if (root.expanded && root.doc.loaded !== true && root.doc.loading !== true)
QbzShell.diagRefresh()
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
panelHeader.activate()
event.accepted = true
}
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "transparent"
border.width: panelHeader.activeFocus ? 2 : 0
border.color: theme.accent
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
@@ -208,16 +257,8 @@ Column {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
root.expanded = !root.expanded
// Gates the publisher: a collapsed panel is not reading, and a
// publish can carry a hundred rows.
QbzShell.diagSetOpen(root.expanded)
// First expand collects; afterwards Refresh is explicit, 1:1
// with DiagnosticsPanel.slint:201-203.
if (root.expanded && root.doc.loaded !== true && root.doc.loading !== true)
QbzShell.diagRefresh()
}
onPressed: panelHeader.forceActiveFocus()
onClicked: panelHeader.activate()
}
}
@@ -20,6 +20,8 @@ Column {
id: root
property var doc: ({})
/// View-level migration setup surface. Kept nullable for isolated previews.
property var migrationSetupModal: null
readonly property var ie: doc.importExport || ({})
QbzTheme { id: theme }
@@ -129,74 +131,13 @@ Column {
SettingRow {
label: QbzSession.tr("Migrate into this account", QbzSession.trRev)
description: (root.ie.snapshots || []).length > 0
? QbzSession.tr("Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing.", QbzSession.trRev)
? QbzSession.tr("Review the saved source profiles, choose what to copy, and confirm the signed-in destination account.", QbzSession.trRev)
: QbzSession.tr("No snapshots yet. Sign in with the old account and create one first.", QbzSession.trRev)
}
// What the local half carries besides library, playlist folders, pinned
// items, blacklist and preferences. Asked every time, all on by
// default (owner decision 2026-09-02); session state, not persisted.
property bool copyLocalProfile: true
property bool copyMediaServers: true
property bool copyScrobblers: true
property bool copyListeningHistory: true
SettingRow {
label: QbzSession.tr("Also copy the local profile", QbzSession.trRev)
description: QbzSession.tr("Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer.", QbzSession.trRev)
QbzToggle {
checked: root.copyLocalProfile
onToggled: function (v) { root.copyLocalProfile = v }
}
}
SettingRow {
rowEnabled: root.copyLocalProfile
label: QbzSession.tr("Media server connections", QbzSession.trRev)
description: QbzSession.tr("Plex, Jellyfin and Subsonic settings, including their credentials.", QbzSession.trRev)
QbzToggle {
checked: root.copyMediaServers
onToggled: function (v) { root.copyMediaServers = v }
}
}
SettingRow {
rowEnabled: root.copyLocalProfile
label: QbzSession.tr("Scrobbler accounts", QbzSession.trRev)
description: QbzSession.tr("Last.fm and ListenBrainz settings, including their credentials.", QbzSession.trRev)
QbzToggle {
checked: root.copyScrobblers
onToggled: function (v) { root.copyScrobblers = v }
}
}
SettingRow {
rowEnabled: root.copyLocalProfile
label: QbzSession.tr("Listening history", QbzSession.trRev)
description: QbzSession.tr("The listen log and the events behind offline recommendations.", QbzSession.trRev)
QbzToggle {
checked: root.copyListeningHistory
onToggled: function (v) { root.copyListeningHistory = v }
}
}
Repeater {
model: root.ie.snapshots || []
delegate: SettingRow {
required property var modelData
label: modelData.label
description: QbzSession.tr("{} favorites · {} playlists · {} followed", QbzSession.trRev)
.replace("{}", modelData.favorites)
.replace("{}", modelData.playlists)
.replace("{}", modelData.subscriptions)
+ (modelData.isCurrentAccount
? " · " + QbzSession.tr("this account", QbzSession.trRev)
: "")
SettingsButton {
text: QbzSession.tr("Migrate…", QbzSession.trRev)
enabled: !root.ie.migrationBusy && !modelData.isCurrentAccount
onClicked: QbzBridge.settingsString("account-migrate", JSON.stringify({
path: modelData.path,
local_profile: root.copyLocalProfile,
media_servers: root.copyMediaServers,
scrobblers: root.copyScrobblers,
listening_history: root.copyListeningHistory
}))
}
SettingsButton {
text: QbzSession.tr("Migrate…", QbzSession.trRev)
enabled: !root.ie.migrationBusy
&& (root.ie.snapshots || []).length > 0
onClicked: if (root.migrationSetupModal) root.migrationSetupModal.open()
}
}
Text {
@@ -134,11 +134,31 @@ Column {
spacing: 8
// Collapse chevron (only when the master toggle is on).
Rectangle {
id: collapseButton
visible: root.doc.scrobbleEnabled === true
width: 28
height: 28
radius: theme.radiusSm
color: colArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: visible && enabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Scrobbling", QbzSession.trRev)
Accessible.onPressAction: collapseButton.activate()
function activate() {
QbzBridge.settingsBool(
"scrobble-collapse", root.doc.scrobbleUiCollapsed !== true)
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
collapseButton.activate()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
// 28px hit-box / 18px glyph (IntegrationsSettings.slint:129).
@@ -152,7 +172,8 @@ Column {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.settingsBool("scrobble-collapse", root.doc.scrobbleUiCollapsed !== true)
onPressed: collapseButton.forceActiveFocus()
onClicked: collapseButton.activate()
}
}
QbzToggle {
@@ -63,7 +63,7 @@ Item {
root.draftOverride = root.st.userOverrideNetwork === true
root.draftFsIndex = root.st.fsTypeIndex || 0
aliasField.text = root.draftAlias
keyScope.forceActiveFocus()
closeButton.forceActiveFocus()
}
function close() {
@@ -81,12 +81,9 @@ Item {
}))
}
// Esc dismiss. No visual and no MouseArea, so it never intercepts a press
// meant for the scrim (the QbzConfirmModal idiom).
FocusScope {
id: keyScope
anchors.fill: parent
Keys.onEscapePressed: root.close()
Keys.onEscapePressed: function (event) {
root.close()
event.accepted = true
}
// Scrim — click dismisses.
@@ -149,12 +146,28 @@ Item {
font.weight: theme.weightSemibold
}
Rectangle {
id: closeButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: theme.radiusSm
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: root.visible
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Close", QbzSession.trRev)
Accessible.onPressAction: root.close()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root.close()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
name: "x"
@@ -167,6 +180,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: closeButton.forceActiveFocus()
onClicked: root.close()
}
}
@@ -422,11 +436,27 @@ Item {
// this same way (:218-232), including the
// `accentGlyphColor` selector for the label.
Rectangle {
id: saveButton
anchors.verticalCenter: parent.verticalCenter
width: saveLabel.implicitWidth + 36
height: 36
radius: theme.radiusSm
color: saveArea.containsMouse ? theme.accentHover : theme.accent
activeFocusOnTab: root.visible
border.width: activeFocus ? 2 : 0
border.color: theme.accentGlyphColor
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Save", QbzSession.trRev)
Accessible.onPressAction: root.save()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root.save()
event.accepted = true
}
}
Text {
id: saveLabel
anchors.centerIn: parent
@@ -440,6 +470,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: saveButton.forceActiveFocus()
onClicked: root.save()
}
}
@@ -44,6 +44,7 @@ Item {
function open() {
root.draftOrder = root.storedOrder()
root._open = true
Qt.callLater(function () { closeButton.forceActiveFocus() })
}
function close() { root._open = false }
@@ -74,6 +75,10 @@ Item {
visible: root._open
enabled: root._open
z: 3100
Keys.onEscapePressed: function (event) {
root.close()
event.accepted = true
}
Rectangle {
anchors.fill: parent
@@ -122,12 +127,28 @@ Item {
font.weight: theme.weightSemibold
}
Rectangle {
id: closeButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 6
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: root.opened
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Close", QbzSession.trRev)
Accessible.onPressAction: root.close()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root.close()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
name: "x"
@@ -140,6 +161,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: closeButton.forceActiveFocus()
onClicked: root.close()
}
}
@@ -234,12 +256,30 @@ Item {
}
Rectangle {
id: resetButton
width: resetRow.width + 24
height: 34
radius: theme.radiusSm
color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
border.color: activeFocus ? theme.accent : theme.borderSubtle
activeFocusOnTab: root.opened
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Reset to defaults", QbzSession.trRev)
Accessible.onPressAction: resetButton.activate()
function activate() {
root.draftOrder = root.defaultOrder.slice()
QbzBridge.settingsString("local-tab-order-reset", "")
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
resetButton.activate()
event.accepted = true
}
}
Row {
id: resetRow
anchors.centerIn: parent
@@ -263,10 +303,8 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.draftOrder = root.defaultOrder.slice()
QbzBridge.settingsString("local-tab-order-reset", "")
}
onPressed: resetButton.forceActiveFocus()
onClicked: resetButton.activate()
}
}
}
@@ -284,6 +322,20 @@ Item {
opacity: reorderRoot.buttonEnabled ? 1.0 : 0.3
color: reorderRoot.buttonEnabled && reorderArea.containsMouse
? theme.surfaceHover : "transparent"
activeFocusOnTab: reorderRoot.buttonEnabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.onPressAction: if (reorderRoot.buttonEnabled) reorderRoot.clicked()
Keys.onPressed: function (event) {
if (reorderRoot.buttonEnabled && !event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
reorderRoot.clicked()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
@@ -298,6 +350,7 @@ Item {
enabled: reorderRoot.buttonEnabled
hoverEnabled: reorderRoot.buttonEnabled
cursorShape: reorderRoot.buttonEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: reorderRoot.forceActiveFocus()
onClicked: reorderRoot.clicked()
}
}
@@ -131,12 +131,32 @@ Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
id: collapseButton
visible: root.state.enabled === true
width: 28
height: 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: chevArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: visible && enabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: root.title
Accessible.onPressAction: collapseButton.activate()
function activate() {
QbzBridge.settingsBool(
root.server + "-collapse", root.state.collapsed !== true)
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
collapseButton.activate()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
name: root.state.collapsed === true ? "chevron-right" : "chevron-down"
@@ -149,8 +169,8 @@ Column {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.settingsBool(
root.server + "-collapse", root.state.collapsed !== true)
onPressed: collapseButton.forceActiveFocus()
onClicked: collapseButton.activate()
}
}
QbzToggle {
@@ -0,0 +1,347 @@
// Full-view account-migration progress. The operation belongs to Rust and
// keeps running if this panel is dismissed; this overlay only makes the
// long-lived phases, account direction and elapsed time explicit.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
property var doc: ({})
readonly property var ie: doc.importExport || ({})
readonly property bool running: ie.migrationRunning === true
readonly property bool succeeded: ie.migrationSucceeded === true
readonly property int currentStep: Math.max(1, ie.migrationStep || 1)
readonly property int stepTotal: Math.max(1, ie.migrationStepTotal || 6)
readonly property int itemDone: Math.max(0, ie.migrationProgressDone || 0)
readonly property int itemTotal: Math.max(0, ie.migrationProgressTotal || 0)
readonly property real itemProgress: itemTotal > 0
? Math.min(1, itemDone / itemTotal) : (running ? 0.08 : 0)
readonly property real overallProgress: (!running && succeeded) ? 1
: Math.max(0, Math.min(1,
((currentStep - 1) + itemProgress) / stepTotal))
property bool openedForRun: false
property bool dismissed: true
property int elapsedSeconds: 0
QbzTheme { id: theme }
function beginRun() {
root.openedForRun = true
root.dismissed = false
root.elapsedSeconds = 0
Qt.callLater(function () { closeButton.forceActiveFocus() })
}
function close() {
root.dismissed = true
root.restoreShellFocus()
}
function restoreShellFocus() {
var item = root
while (item.parent) {
if (item.parent.isQbzShellRoot === true) {
item.parent.forceActiveFocus()
return
}
item = item.parent
}
}
function elapsedLabel() {
const minutes = Math.floor(root.elapsedSeconds / 60)
const seconds = root.elapsedSeconds % 60
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds
}
function stepLabel(step) {
switch (step) {
case 1: return QbzSession.tr("Reading this account…", QbzSession.trRev)
case 2: return QbzSession.tr("Adding favorites…", QbzSession.trRev)
case 3: return QbzSession.tr("Adding playlists…", QbzSession.trRev)
case 4: return QbzSession.tr("Following playlists…", QbzSession.trRev)
case 5: return QbzSession.tr("Copying local profile…", QbzSession.trRev)
default: return QbzSession.tr("Refreshing migrated data…", QbzSession.trRev)
}
}
onRunningChanged: if (running) beginRun()
Component.onCompleted: if (running) beginRun()
Timer {
interval: 1000
repeat: true
running: root.running
onTriggered: root.elapsedSeconds += 1
}
visible: openedForRun && !dismissed
enabled: visible
z: 3200
Keys.onEscapePressed: function (event) {
root.close()
event.accepted = true
}
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.close()
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - 80, 620)
height: Math.min(parent.height * 0.9, 680)
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea {
anchors.fill: parent
onWheel: function (wheel) { wheel.accepted = true }
}
Item {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 68
Text {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: headerSpinner.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("ACCOUNT MIGRATION", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
QbzSpinner {
id: headerSpinner
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
size: 20
visible: root.running
spinning: root.running
}
}
Rectangle {
id: headerDivider
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.borderSubtle
}
Item {
id: footer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 70
SettingsButton {
id: closeButton
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Close", QbzSession.trRev)
onClicked: root.close()
}
}
Rectangle {
id: footerDivider
anchors.bottom: footer.top
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.borderSubtle
}
Flickable {
anchors.top: headerDivider.bottom
anchors.bottom: footerDivider.top
anchors.left: parent.left
anchors.right: parent.right
clip: true
contentWidth: width
contentHeight: body.implicitHeight + 48
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
x: 24
y: 24
width: parent.width - 48
spacing: 16
WarningBanner {
width: parent.width
variant: "warning"
title: QbzSession.tr("This only happens once. Please wait for it to finish and do not close the window.", QbzSession.trRev)
}
Text {
width: parent.width
text: QbzSession.tr("You can close this panel; the migration will continue in the background.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: identityCol.implicitHeight + 24
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: identityCol
x: 12
y: 12
width: parent.width - 24
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("From", QbzSession.trRev)
+ ": " + (root.ie.migrationSource || "—")
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: QbzSession.tr("To", QbzSession.trRev)
+ ": " + (root.ie.migrationTarget || "—")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
}
}
Column {
width: parent.width
spacing: 8
Item {
width: parent.width
height: 20
Text {
anchors.left: parent.left
anchors.right: elapsed.right
anchors.rightMargin: 12
text: QbzSession.tr("Step {} of {}", QbzSession.trRev)
.replace("{}", root.currentStep)
.replace("{}", root.stepTotal)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
id: elapsed
anchors.right: parent.right
text: QbzSession.tr("Elapsed: {}", QbzSession.trRev)
.replace("{}", root.elapsedLabel())
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
Rectangle {
width: parent.width
height: 10
radius: 5
color: theme.borderSubtle
clip: true
Rectangle {
width: parent.width * root.overallProgress
height: parent.height
radius: 5
color: theme.accent
}
}
Text {
width: parent.width
text: root.ie.migrationStatus || root.stepLabel(root.currentStep)
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
}
Column {
width: parent.width
spacing: 6
Repeater {
model: root.stepTotal
delegate: Item {
required property int index
readonly property int number: index + 1
readonly property bool complete: number < root.currentStep
|| (!root.running && root.succeeded)
readonly property bool active: number === root.currentStep
&& root.running
width: parent ? parent.width : 0
height: 30
Rectangle {
id: stepDot
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
radius: 12
color: parent.complete || parent.active
? theme.accent : "transparent"
border.width: parent.complete || parent.active ? 0 : 1
border.color: theme.borderSubtle
Text {
anchors.centerIn: parent
text: stepDot.parent.complete ? "✓" : stepDot.parent.number
color: stepDot.parent.complete || stepDot.parent.active
? theme.onAccent : theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
Text {
anchors.left: stepDot.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.stepLabel(parent.number)
color: parent.active ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: parent.active
? theme.weightSemibold : theme.weightRegular
elide: Text.ElideRight
}
}
}
}
}
}
}
}
@@ -0,0 +1,367 @@
// Account-migration preparation surface. Selection and copy options live here
// rather than in the Settings page so the user reviews one explicit
// source -> authenticated destination before the long-running operation.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
property var doc: ({})
property var confirmHost: null
readonly property var ie: doc.importExport || ({})
readonly property var profiles: ie.snapshots || []
readonly property var selectedProfile: {
for (let i = 0; i < profiles.length; i++) {
if (profiles[i].path === selectedPath)
return profiles[i]
}
return null
}
property bool opened: false
property string selectedPath: ""
property bool copyLocalProfile: true
property bool copyMediaServers: true
property bool copyScrobblers: true
property bool copyListeningHistory: true
QbzTheme { id: theme }
function open() {
root.selectedPath = ""
root.copyLocalProfile = true
root.copyMediaServers = true
root.copyScrobblers = true
root.copyListeningHistory = true
root.opened = true
Qt.callLater(function () { closeButton.forceActiveFocus() })
}
function close() {
root.opened = false
root.restoreShellFocus()
}
function restoreShellFocus() {
var item = root
while (item.parent) {
if (item.parent.isQbzShellRoot === true) {
item.parent.forceActiveFocus()
return
}
item = item.parent
}
}
function askDelete(path) {
if (!root.confirmHost)
return
root.confirmHost.ask(
QbzSession.tr("Delete migration snapshot?", QbzSession.trRev),
QbzSession.tr("This removes only the migration bundle. The old account's QBZ profile and user data are kept.", QbzSession.trRev),
QbzSession.tr("Delete snapshot", QbzSession.trRev),
function () {
if (root.selectedPath === path)
root.selectedPath = ""
QbzBridge.settingsString("account-delete-snapshot", path)
})
}
function startMigration() {
if (!root.selectedProfile || root.ie.migrationBusy)
return
QbzBridge.settingsString("account-migrate", JSON.stringify({
path: root.selectedProfile.path,
local_profile: root.copyLocalProfile,
media_servers: root.copyMediaServers,
scrobblers: root.copyScrobblers,
listening_history: root.copyListeningHistory
}))
root.close()
}
visible: root.opened
enabled: root.opened
z: 3050
Keys.onEscapePressed: function (event) {
root.close()
event.accepted = true
}
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.close()
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - 80, 760)
height: Math.min(parent.height - 48, 780)
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea {
anchors.fill: parent
onWheel: function (wheel) { wheel.accepted = true }
}
Item {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 68
Text {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Migrate account data", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
}
Rectangle {
id: headerDivider
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.borderSubtle
}
Item {
id: footer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 70
Row {
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
spacing: 10
SettingsButton {
id: closeButton
minWidth: 100
text: QbzSession.tr("Cancel", QbzSession.trRev)
onClicked: root.close()
}
SettingsButton {
minWidth: 160
text: QbzSession.tr("Start migration", QbzSession.trRev)
enabled: root.selectedProfile !== null && !root.ie.migrationBusy
onClicked: root.startMigration()
}
}
}
Rectangle {
id: footerDivider
anchors.bottom: footer.top
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.borderSubtle
}
Flickable {
id: flick
anchors.top: headerDivider.bottom
anchors.bottom: footerDivider.top
anchors.left: parent.left
anchors.right: parent.right
clip: true
contentWidth: width
contentHeight: body.implicitHeight + 48
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
x: 24
y: 24
width: parent.width - 48
spacing: 12
WarningBanner {
width: parent.width
variant: "info"
title: QbzSession.tr("What is a migration profile?", QbzSession.trRev)
body: QbzSession.tr("It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data.", QbzSession.trRev)
}
GroupHeader { text: QbzSession.tr("SAVED SOURCE PROFILES", QbzSession.trRev) }
Text {
visible: root.profiles.length === 0
width: parent.width
text: QbzSession.tr("No saved migration profiles are available.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Repeater {
model: root.profiles
delegate: Rectangle {
required property var modelData
readonly property bool selected:
root.selectedPath === modelData.path
width: body.width
height: 92
radius: theme.radiusMd
color: selected ? theme.surfaceHover : theme.surfaceElevated
border.width: selected ? 2 : 1
border.color: selected ? theme.accent : theme.borderSubtle
Column {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.right: actions.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
width: parent.width
text: modelData.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: QbzSession.tr("{} favorites · {} playlists · {} followed", QbzSession.trRev)
.replace("{}", modelData.favorites)
.replace("{}", modelData.playlists)
.replace("{}", modelData.subscriptions)
+ (modelData.isCurrentAccount
? " · " + QbzSession.tr("this account", QbzSession.trRev)
: "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
id: actions
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
minWidth: 92
text: parent.parent.selected
? QbzSession.tr("Selected", QbzSession.trRev)
: QbzSession.tr("Select", QbzSession.trRev)
enabled: !modelData.isCurrentAccount
&& !root.ie.migrationBusy
onClicked: root.selectedPath = modelData.path
}
SettingsButton {
minWidth: 92
text: QbzSession.tr("Delete…", QbzSession.trRev)
danger: true
enabled: !root.ie.migrationBusy
onClicked: root.askDelete(modelData.path)
}
}
}
}
GroupHeader { text: QbzSession.tr("MIGRATION DIRECTION", QbzSession.trRev) }
Rectangle {
width: parent.width
height: direction.implicitHeight + 24
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: direction
x: 12
y: 12
width: parent.width - 24
spacing: 8
Text {
width: parent.width
text: QbzSession.tr("From", QbzSession.trRev) + ": "
+ (root.selectedProfile
? root.selectedProfile.sourceIdentity
: QbzSession.tr("Select a source profile above", QbzSession.trRev))
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: QbzSession.tr("To", QbzSession.trRev) + ": "
+ QbzSession.tr("Signed-in Qobuz account · ID {}", QbzSession.trRev)
.replace("{}", root.ie.currentUserId || "—")
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
}
}
GroupHeader { text: QbzSession.tr("WHAT TO COPY", QbzSession.trRev) }
SettingRow {
label: QbzSession.tr("Also copy the local profile", QbzSession.trRev)
description: QbzSession.tr("Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer.", QbzSession.trRev)
QbzToggle {
checked: root.copyLocalProfile
onToggled: function (value) { root.copyLocalProfile = value }
}
}
SettingRow {
rowEnabled: root.copyLocalProfile
label: QbzSession.tr("Media server connections", QbzSession.trRev)
description: QbzSession.tr("Plex, Jellyfin and Subsonic settings, including their credentials.", QbzSession.trRev)
QbzToggle {
checked: root.copyMediaServers
enabled: root.copyLocalProfile
onToggled: function (value) { root.copyMediaServers = value }
}
}
SettingRow {
rowEnabled: root.copyLocalProfile
label: QbzSession.tr("Scrobbler accounts", QbzSession.trRev)
description: QbzSession.tr("Last.fm and ListenBrainz settings, including their credentials.", QbzSession.trRev)
QbzToggle {
checked: root.copyScrobblers
enabled: root.copyLocalProfile
onToggled: function (value) { root.copyScrobblers = value }
}
}
SettingRow {
rowEnabled: root.copyLocalProfile
label: QbzSession.tr("Listening history", QbzSession.trRev)
description: QbzSession.tr("The listen log and the events behind offline recommendations.", QbzSession.trRev)
QbzToggle {
checked: root.copyListeningHistory
enabled: root.copyLocalProfile
onToggled: function (value) { root.copyListeningHistory = value }
}
}
WarningBanner {
width: parent.width
variant: "warning"
title: QbzSession.tr("This only happens once. Please wait for it to finish and do not close the window.", QbzSession.trRev)
body: QbzSession.tr("Migration is additive: it does not remove favorites or playlists from either account.", QbzSession.trRev)
}
}
}
}
}
+22 -1
View File
@@ -133,12 +133,32 @@ Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Rectangle {
id: collapseButton
visible: QbzLocal.plexEnabled
width: 28
height: 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: chevArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: visible && enabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: "Plex"
Accessible.onPressAction: collapseButton.activate()
function activate() {
QbzBridge.settingsBool(
"plex-collapse", root.plex.collapsed !== true)
}
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
collapseButton.activate()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
name: root.plex.collapsed === true ? "chevron-right" : "chevron-down"
@@ -151,7 +171,8 @@ Column {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.settingsBool("plex-collapse", root.plex.collapsed !== true)
onPressed: collapseButton.forceActiveFocus()
onClicked: collapseButton.activate()
}
}
QbzToggle {
+85 -4
View File
@@ -24,6 +24,18 @@
// republishes, and the rows re-render from the new document (the Slint
// "single source of truth in SettingsState" pattern).
//
// KEYBOARD FOCUS CONTRACT (for every new setting): Qt Quick has no HTML-like
// numeric tabindex; its tab index is the declaration order of enabled,
// visible items with `activeFocusOnTab`. Therefore settings and their controls
// MUST be declared in the same top-to-bottom order in which they are drawn and
// MUST use the shared QbzToggle/QbzSelect/QbzSlider/QbzLineEdit/SettingsButton/
// QbzCheckbox controls. Do not put a bare MouseArea on a new setting action:
// the shared controls provide the focus ring and Enter/Space activation, and
// accept Space so it cannot bubble into AppShell's global play/pause hotkey.
// If a custom interactive item is genuinely necessary, it inherits this same
// contract. This comment is intentionally at the composition root so both
// contributors and coding agents encounter it when adding a settings row.
//
// The NavButtons row inside the 92px header is a 0px placeholder — nav
// history lives in the global HeaderBar in this port, like every other view.
@@ -53,6 +65,8 @@ Item {
// `root.section = n` would work once and then re-introduce the bug one
// navigation later, in a way that looks fixed under casual testing.
readonly property int section: QbzBridge.settingsSection
readonly property bool migrationRunning:
(doc.importExport || ({})).migrationRunning === true
readonly property bool sandboxed: {
const m = (doc.dev || ({})).installMethod || ""
@@ -74,8 +88,12 @@ Item {
// ============================ the view ================================
Column {
id: settingsContent
anchors.fill: parent
spacing: 0
// A migration may be hidden, but its settings tree remains inert until
// Rust finishes. Global navigation is outside this view and stays live.
enabled: !root.migrationRunning
// --- Header (92px; NavButtons is a 0px placeholder in this port) --
Item {
@@ -114,6 +132,22 @@ Item {
radius: theme.radiusSm
color: active ? theme.surfaceElevated
: snArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: visible && enabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: label
Accessible.onPressAction: clicked()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
clicked()
event.accepted = true
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
@@ -144,6 +178,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: parent.forceActiveFocus()
onClicked: parent.clicked()
}
}
@@ -260,6 +295,37 @@ Item {
clip: true
boundsBehavior: Flickable.StopAtBounds
// Tab can move into a control below the viewport. Reveal
// that control as focus changes so keyboard navigation is
// never technically correct but visually lost offscreen.
function revealFocusItem(item) {
if (!item)
return
var ancestor = item
while (ancestor && ancestor !== panelCol)
ancestor = ancestor.parent
if (ancestor !== panelCol)
return
const point = item.mapToItem(flick.contentItem, 0, 0)
const margin = 12
if (point.y < flick.contentY + margin)
flick.contentY = Math.max(0, point.y - margin)
else if (point.y + item.height > flick.contentY + flick.height - margin)
flick.contentY = Math.min(
Math.max(0, flick.contentHeight - flick.height),
point.y + item.height - flick.height + margin)
}
Connections {
target: root.Window.window
function onActiveFocusItemChanged() {
const win = root.Window.window
Qt.callLater(function () {
flick.revealFocusItem(win ? win.activeFocusItem : null)
})
}
}
Column {
id: panelCol
x: 20
@@ -332,7 +398,7 @@ Item {
OfflineSettings {
width: parent.width
doc: root.doc
confirmHost: confirmHost
confirmHost: settingsConfirmHost
}
}
}
@@ -342,7 +408,7 @@ Item {
LocalLibrarySettings {
width: parent.width
doc: root.doc
confirmHost: confirmHost
confirmHost: settingsConfirmHost
tabOrderModal: localTabsConfigModal
}
}
@@ -362,7 +428,7 @@ Item {
IntegrationsSettings {
width: parent.width
doc: root.doc
confirmHost: confirmHost
confirmHost: settingsConfirmHost
}
}
}
@@ -372,6 +438,7 @@ Item {
ImportExportSettings {
width: parent.width
doc: root.doc
migrationSetupModal: migrationSetupOverlay
}
}
}
@@ -416,7 +483,7 @@ Item {
// declaration order alone puts it over every panel and the sub-nav.
// Panels that need it are handed the reference (see LocalLibrarySettings
// / PlexSettings `confirmHost`).
SettingsConfirmHost { id: confirmHost }
SettingsConfirmHost { id: settingsConfirmHost }
// App-wide Local Library order. Mounted at the view root so its scrim
// covers the sub-navigation and the scrolled panel alike.
@@ -440,4 +507,18 @@ Item {
// area, and the reference is an overlay inside the app shell too, not a
// separate window (DacWizardModal.slint:8-9).
DacWizardModal { }
MigrationSetupModal {
id: migrationSetupOverlay
anchors.fill: parent
doc: root.doc
confirmHost: settingsConfirmHost
}
// Mounted last so a long account migration has one unmistakable progress
// surface over every Settings section. Closing it never cancels the task.
MigrationProgressModal {
anchors.fill: parent
doc: root.doc
}
}