diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index de474c4d..b0ccb8e4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -436,6 +436,7 @@ if(NOT BUILD_CLI_ONLY) wizard/dialogs/ConfirmSystemDriveDialog.qml wizard/dialogs/KeychainPermissionDialog.qml wizard/dialogs/UpdateAvailableDialog.qml + wizard/dialogs/RepositoryDialog.qml wizard/dialogs/AppOptionsDialog.qml wizard/LanguageSelectionStep.qml qmlcomponents/BaseDialog.qml @@ -448,6 +449,7 @@ if(NOT BUILD_CLI_ONLY) qmlcomponents/ImPopup.qml qmlcomponents/ImRadioButton.qml qmlcomponents/ImOptionPill.qml + qmlcomponents/ImOptionButton.qml qmlcomponents/ImTextField.qml qmlcomponents/SelectionListView.qml qmlcomponents/OSSelectionListView.qml diff --git a/src/qmlcomponents/BaseDialog.qml b/src/qmlcomponents/BaseDialog.qml index 3307fed6..7c1ca97d 100644 --- a/src/qmlcomponents/BaseDialog.qml +++ b/src/qmlcomponents/BaseDialog.qml @@ -134,7 +134,7 @@ Dialog { function registerFocusGroup(name, getItemsFn, order) { dialogFocusScope.registerFocusGroup(name, getItemsFn, order) } - + function rebuildFocusOrder() { dialogFocusScope.rebuildFocusOrder() } diff --git a/src/qmlcomponents/ImOptionButton.qml b/src/qmlcomponents/ImOptionButton.qml new file mode 100644 index 00000000..5a5de566 --- /dev/null +++ b/src/qmlcomponents/ImOptionButton.qml @@ -0,0 +1,91 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright (C) 2025 Raspberry Pi Ltd + */ + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import RpiImager +import QtQuick.Controls.Material 2.15 + +// A labeled button styled for Imager; only the button clicks, not the whole row +Item { + id: control + property alias text: label.text + property bool checked: false + // Optional help link next to the label + property string helpLabel: "" + property url helpUrl: "" + property string btnText: "" + signal clicked() + + // Expose the actual focusable control for tab navigation + property alias focusItem: optionButton + + implicitHeight: Math.max(Style.buttonHeightStandard - 8, 28) + implicitWidth: label.implicitWidth + optionButton.implicitWidth + Style.cardPadding + + RowLayout { + anchors.fill: parent + spacing: Style.spacingMedium + + // Text block (label + optional help) on the left + ColumnLayout { + id: textColumn + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: Style.spacingXXSmall + + // Main label + Text { + id: label + Layout.alignment: Qt.AlignVCenter + font.family: Style.fontFamilyBold + font.pixelSize: Style.fontSizeFormLabel + font.bold: true + color: Style.formLabelColor + elide: Text.ElideRight + TapHandler { onTapped: sw.toggle() } + } + + // Optional help link under the label + Text { + id: helpText + Layout.alignment: Qt.AlignVCenter + visible: control.helpLabel !== "" && control.helpUrl !== "" + text: control.helpLabel + font.family: Style.fontFamily + font.pixelSize: Style.fontSizeDescription + color: Style.buttonForegroundColor + font.underline: helpHover.hovered + Accessible.name: text + TapHandler { + cursorShape: Qt.PointingHandCursor + onTapped: Qt.openUrlExternally(pill.helpUrl) + } + HoverHandler { + id: helpHover + acceptedDevices: PointerDevice.Mouse + cursorShape: Qt.PointingHandCursor + } + } + } + + // Flexible spacer to push the switch flush-right and align across rows + Item { Layout.fillWidth: true } + + ImButton { + id: optionButton + Layout.alignment: Qt.AlignVCenter + activeFocusOnTab: true + text: btnText + + onClicked: { + control.clicked() + } + } + } + + function forceActiveFocus() { optionButton.forceActiveFocus() } +} diff --git a/src/qmlcomponents/ImRadioButton.qml b/src/qmlcomponents/ImRadioButton.qml index 0d6c2fb6..8c01b059 100644 --- a/src/qmlcomponents/ImRadioButton.qml +++ b/src/qmlcomponents/ImRadioButton.qml @@ -1,11 +1,11 @@ /* * SPDX-License-Identifier: Apache-2.0 - * Copyright (C) 2022 Raspberry Pi Ltd + * Copyright (C) 2022-2025 Raspberry Pi Ltd */ -import QtQuick 2.9 -import QtQuick.Controls 2.2 -import QtQuick.Controls.Material 2.2 +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material RadioButton { Material.accent: Style.formControlActiveColor @@ -24,10 +24,20 @@ RadioButton { Keys.onPressed: (event) => { if (event.key === Qt.Key_Space) { - toggle() + if (!checked) // prevent unchecking the current one + click() // goes through the normal “mouse click” path event.accepted = true } } - Keys.onEnterPressed: toggle() - Keys.onReturnPressed: toggle() + Keys.onEnterPressed: (event) => { + if (!checked) + click() + event.accepted = true + } + + Keys.onReturnPressed: (event) => { + if (!checked) + click() + event.accepted = true + } } diff --git a/src/wizard/DeviceSelectionStep.qml b/src/wizard/DeviceSelectionStep.qml index 76c02f87..91fe6c88 100644 --- a/src/wizard/DeviceSelectionStep.qml +++ b/src/wizard/DeviceSelectionStep.qml @@ -59,16 +59,15 @@ WizardStepBase { if (!root || !root.hwModel) { return } - // Only reload if we haven't loaded yet or if the model is empty - if (!modelLoaded || root.hwModel.rowCount() === 0) { - console.log("DeviceSelectionStep: OS list prepared, reloading hardware model") - var success = root.hwModel.reload() - if (success) { - modelLoaded = true - // Set initial focus for keyboard navigation if no device is selected - if (hwlist.currentIndex === -1 && root.hwModel.rowCount() > 0) { - hwlist.currentIndex = 0 - } + + // Don't guard with modelLoaded to support reloading when repo changed + console.log("DeviceSelectionStep: OS list prepared, reloading hardware model") + var success = root.hwModel.reload() + if (success) { + modelLoaded = true + // Set initial focus for keyboard navigation if no device is selected + if (hwlist.currentIndex === -1 && root.hwModel.rowCount() > 0) { + hwlist.currentIndex = 0 } } } diff --git a/src/wizard/WizardContainer.qml b/src/wizard/WizardContainer.qml index a071f448..25dbf6a2 100644 --- a/src/wizard/WizardContainer.qml +++ b/src/wizard/WizardContainer.qml @@ -591,6 +591,8 @@ Item { if (!root.optionsPopup.wizardContainer) { root.optionsPopup.wizardContainer = root } + // TODO: actually duplicate + // as onOpen in it already calls initialize() root.optionsPopup.initialize() root.optionsPopup.open() } diff --git a/src/wizard/dialogs/AppOptionsDialog.qml b/src/wizard/dialogs/AppOptionsDialog.qml index a347ed41..ee7cbbc5 100644 --- a/src/wizard/dialogs/AppOptionsDialog.qml +++ b/src/wizard/dialogs/AppOptionsDialog.qml @@ -23,8 +23,6 @@ BaseDialog { property var wizardContainer: null property bool initialized: false - property url selectedRepo: "" - property url originalRepo: "" // Custom escape handling function escapePressed() { @@ -34,24 +32,14 @@ BaseDialog { // Register focus groups when component is ready Component.onCompleted: { registerFocusGroup("options", function(){ - return [chkBeep.focusItem, chkEject.focusItem, chkTelemetry.focusItem, chkDisableWarnings.focusItem] + return [chkBeep.focusItem, chkEject.focusItem, chkTelemetry.focusItem, + chkDisableWarnings.focusItem, editRepoButton.focusItem] }, 0) - registerFocusGroup("repository", function(){ - return [fieldCustomRepository, browseButton] - }, 1) registerFocusGroup("buttons", function(){ return [cancelButton, saveButton] }, 2) } - Connections { - target: imageWriter - // Handle native file selection for "Use custom" - function onFileSelected(fileUrl) { - popup.selectedRepo = fileUrl; - } - } - // Header Text { text: qsTr("App Options") @@ -120,37 +108,26 @@ BaseDialog { } } - RowLayout { + ImOptionButton { + id: editRepoButton + text: qsTr("Content Repository") + btnText: qsTr("Edit") Layout.fillWidth: true - spacing: Style.spacingMedium - - ImTextField { - id: fieldCustomRepository - text: selectedRepo !== "" ? UrlFmt.display(selectedRepo) : "" - Layout.fillWidth: true - placeholderText: qsTr("Select custom Repository") - font.pixelSize: Style.fontSizeInput - readOnly: true - activeFocusOnTab: true + Component.onCompleted: { + focusItem.activeFocusOnTab = true } - - ImButton { - id: browseButton - text: qsTr("Browse") - Layout.minimumWidth: 80 - activeFocusOnTab: true - onClicked: { - // Prefer native file dialog via Imager's wrapper, but only if available - if (imageWriter.nativeFileDialogAvailable()) { - // Defer opening the native dialog until after the current event completes - Qt.callLater(function () { - imageWriter.openFileDialog(qsTr("Select Repository"), CommonStrings.repoFiltersString); - }); - } else { - // Fallback to QML dialog (forced non-native) - repoFileDialog.open(); - } + onClicked: { + // TODO: close this dialog - open sub dialog - show optin between default hardcoded and custom repo and for custom repo browser local or url + // verify repo on save - don't persist longer then app is running + //popup.close() + //Qt.callLater(function () { + //open + //}); + if (!repoDialog.wizardContainer) { + repoDialog.wizardContainer = popup.wizardContainer } + //repoDialog.initialize() + repoDialog.open() } } } @@ -200,6 +177,13 @@ BaseDialog { } } + RepositoryDialog { + id: repoDialog + parent: popup.parent + imageWriter: popup.imageWriter + wizardContainer: popup.wizardContainer + } + function initialize() { if (!initialized) { // Load current settings from ImageWriter @@ -208,13 +192,6 @@ BaseDialog { chkTelemetry.checked = imageWriter.getBoolSetting("telemetry"); // Do not load from QSettings; keep ephemeral chkDisableWarnings.checked = popup.wizardContainer ? popup.wizardContainer.disableWarnings : false; - if (imageWriter.customRepo()) { - selectedRepo = imageWriter.constantOsListUrl(); - originalRepo = selectedRepo; - } else { - selectedRepo = ""; - originalRepo = ""; - } initialized = true; // Pre-compute final height before opening to avoid first-show reflow @@ -231,16 +208,6 @@ BaseDialog { // Do not persist disable_warnings; set ephemeral flag only if (popup.wizardContainer) popup.wizardContainer.disableWarnings = chkDisableWarnings.checked; - // Only save repository setting if it has actually changed - if (popup.selectedRepo !== popup.originalRepo) { - if (popup.selectedRepo !== "") { - imageWriter.refreshOsListFrom(selectedRepo); - } else { - // User cleared the repository - reset to default - // Note: setCustomRepo with the default URL will reset to default behavior - imageWriter.setCustomRepo(Qt.resolvedUrl("https://downloads.raspberrypi.org/os_list_imagingutility_v4.json")); - } - } } onOpened: { @@ -323,18 +290,4 @@ BaseDialog { } } } - - property alias repoFileDialog: repoFileDialog - - ImFileDialog { - id: repoFileDialog - dialogTitle: qsTr("Select custom repository") - nameFilters: CommonStrings.repoFiltersList - onAccepted: { - popup.selectedRepo = selectedFile; - } - onRejected: - // No-op; user cancelled - {} - } -} \ No newline at end of file +} diff --git a/src/wizard/dialogs/RepositoryDialog.qml b/src/wizard/dialogs/RepositoryDialog.qml new file mode 100644 index 00000000..febff06c --- /dev/null +++ b/src/wizard/dialogs/RepositoryDialog.qml @@ -0,0 +1,274 @@ +import QtCore +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import "../../qmlcomponents" + +import RpiImager + +BaseDialog { + id: popup + + required property ImageWriter imageWriter + property var wizardContainer: null + + property bool initialized: false + property url selectedRepo: "" + property string customRepoUri: "" + property url originalRepo: "" + + Component.onCompleted: { + registerFocusGroup("sourceTypes", function(){ + return [radioOfficial, radioCustomFile, radioCustomUri] + }, 0) + registerFocusGroup("customFile", function(){ + return radioCustomFile.checked ? [fieldCustomRepository, browseButton] : [] + }, 1) + registerFocusGroup("customUri", function(){ + return radioCustomUri.checked ? [fieldCustomUri] : [] + }, 2) + registerFocusGroup("buttons", function(){ + return [cancelButton, saveButton] + }, 3) + } + + Connections { + target: imageWriter + function onFileSelected(fileUrl) { + popup.selectedRepo = fileUrl + } + } + + // Header + Text { + text: qsTr("Content Repository") + font.pixelSize: Style.fontSizeLargeHeading + font.family: Style.fontFamilyBold + font.bold: true + color: Style.formLabelColor + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + + // Options section + Item { + Layout.fillWidth: true + Layout.preferredHeight: optionsLayout.implicitHeight + Style.cardPadding + + ColumnLayout { + id: optionsLayout + anchors.fill: parent + anchors.margins: Style.cardPadding + spacing: Style.spacingMedium + + WizardFormLabel { + text: qsTr("Repository source:") + } + + ButtonGroup { id: repoGroup } + + ImRadioButton { + id: radioOfficial + text: "Raspberry Pi (default)" + checked: true + ButtonGroup.group: repoGroup + } + + ImRadioButton { + id: radioCustomFile + text: qsTr("Use custom file") + checked: false + ButtonGroup.group: repoGroup + } + + ImRadioButton { + id: radioCustomUri + text: qsTr("Use custom URI") + checked: false + ButtonGroup.group: repoGroup + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.spacingMedium + visible: radioCustomFile.checked + + ImTextField { + id: fieldCustomRepository + text: selectedRepo !== "" ? UrlFmt.display(selectedRepo) : "" + Layout.fillWidth: true + placeholderText: qsTr("Please select a custom repository json file") + font.pixelSize: Style.fontSizeInput + readOnly: true + activeFocusOnTab: true + } + + ImButton { + id: browseButton + text: qsTr("Browse") + Layout.minimumWidth: 80 + activeFocusOnTab: true + onClicked: { + // Prefer native file dialog via Imager's wrapper, but only if available + if (imageWriter.nativeFileDialogAvailable()) { + // Defer opening the native dialog until after the current event completes + Qt.callLater(function () { + imageWriter.openFileDialog(qsTr("Select Repository"), CommonStrings.repoFiltersString); + }); + } else { + // Fallback to QML dialog (forced non-native) + repoFileDialog.open(); + } + } + } + } + + ImTextField { + id: fieldCustomUri + visible: radioCustomUri.checked + Layout.fillWidth: true + text: customRepoUri + placeholderText: "https://path.to.my/repo.json" + font.pixelSize: Style.fontSizeInput + activeFocusOnTab: true + inputMethodHints: Qt.ImhUrlCharactersOnly + + validator: RegularExpressionValidator { + // accept http and https and the linked file must end on .json + regularExpression: /^https?:\/\/\S+\.json$/i + } + } + } + } + + // Spacer + Item { + Layout.fillHeight: true + } + + // Buttons section with background + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: buttonRow.implicitHeight + Style.cardPadding + color: Style.titleBackgroundColor + + RowLayout { + id: buttonRow + anchors.fill: parent + anchors.margins: Style.cardPadding / 2 + spacing: Style.spacingMedium + + Item { + Layout.fillWidth: true + } + + ImButton { + id: cancelButton + text: qsTr("Cancel") + Layout.minimumWidth: Style.buttonWidthMinimum + activeFocusOnTab: true + onClicked: { + popup.initialized = false + popup.close(); + } + } + + ImButtonRed { + id: saveButton + enabled: radioOfficial.checked + || (radioCustomFile.checked && popup.selectedRepo.toString() !== "") + || (radioCustomUri.checked && fieldCustomUri.acceptableInput) + // TODO: only show or enable when settings changed + text: qsTr("Apply & Restart") + Layout.minimumWidth: Style.buttonWidthMinimum + activeFocusOnTab: true + onClicked: { + popup.applySettings(); + popup.close(); + } + } + } + } + + Connections { + target: repoGroup + function onCheckedButtonChanged() { + popup.rebuildFocusOrder() + } + } + + function initialize() { + if (!initialized) { + initialized = true + + if (imageWriter.customRepo()) { + var repo = new URL(imageWriter.constantOsListUrl()) + if (repo.protocol === "file:") { + radioOfficial.checked = false + radioCustomFile.checked = true + radioCustomUri.checked = false + selectedRepo = repo + } else if (repo.protocol === "http:" || repo.protocol === "https:") { + radioOfficial.checked = false + radioCustomFile.checked = false + radioCustomUri.checked = true + customRepoUri = repo.toString() + } else { + radioOfficial.checked = true + radioCustomFile.checked = false + radioCustomUri.checked = false + } + + originalRepo = selectedRepo + } else { + radioOfficial.checked = true + radioCustomFile.checked = false + radioCustomUri.checked = false + selectedRepo = "" + customRepoUri = "" + originalRepo = "" + } + + // Ensure focus order is built after initial state + popup.rebuildFocusOrder() + } + } + + function applySettings() { + // Save settings to ImageWriter + // Only save repository setting if it has actually changed + if (radioOfficial.checked && originalRepo !== "") { + // TODO: helper function in imageWriter that retrieves the original url from static property + imageWriter.refreshOsListFrom(new URL("https://downloads.raspberrypi.org/os_list_imagingutility_v4.json")) + // reset wizard to device selection because the repository changed + wizardContainer.resetWizard() + } else if (radioCustomFile.checked && originalRepo !== selectedRepo) { + imageWriter.refreshOsListFrom(selectedRepo) + // reset wizard to device selection because the repository changed + wizardContainer.resetWizard() + } else if (radioCustomUri.checked && originalRepo.toString() !== customRepoUri) { + imageWriter.refreshOsListFrom(new URL(fieldCustomUri.text)) + // reset wizard to device selection because the repository changed + wizardContainer.resetWizard() + } + } + + onOpened: { + initialize() + } + + property alias repoFileDialog: repoFileDialog + + ImFileDialog { + id: repoFileDialog + dialogTitle: qsTr("Select custom repository") + nameFilters: CommonStrings.repoFiltersList + onAccepted: { + popup.selectedRepo = selectedFile; + } + onRejected: + // No-op; user cancelled + {} + } +}