From c7956eed8b68077dbba409571da605f3687b10ce Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 9 Feb 2026 14:03:38 +0100 Subject: [PATCH] QML: runner inventory, prefix actions, and runner error notifications --- src/qml/DiagnosticsSheet.qml | 18 ++ src/qml/GameEditDialog.qml | 337 +++++++++++++++++++++++++++++------ src/qml/Main.qml | 16 ++ src/qml/SettingsPage.qml | 163 +++++++++++++++++ 4 files changed, 476 insertions(+), 58 deletions(-) diff --git a/src/qml/DiagnosticsSheet.qml b/src/qml/DiagnosticsSheet.qml index 6e3184a..e573ab9 100644 --- a/src/qml/DiagnosticsSheet.qml +++ b/src/qml/DiagnosticsSheet.qml @@ -97,6 +97,18 @@ Kirigami.OverlaySheet { wrapMode: Text.WrapAnywhere } + QQC2.Label { + Kirigami.FormData.label: i18n("Runner ID:") + text: launchInfo && launchInfo.runnerId ? launchInfo.runnerId : i18n("None") + wrapMode: Text.WrapAnywhere + } + + QQC2.Label { + Kirigami.FormData.label: i18n("Runner path:") + text: launchInfo && launchInfo.runnerPath ? launchInfo.runnerPath : i18n("Not specified") + wrapMode: Text.WrapAnywhere + } + QQC2.Label { Kirigami.FormData.label: i18n("Program:") text: launchInfo && launchInfo.finalProgram ? launchInfo.finalProgram : "" @@ -120,6 +132,12 @@ Kirigami.OverlaySheet { text: launchInfo && launchInfo.resolvedPrefixPath ? launchInfo.resolvedPrefixPath : (launchInfo && launchInfo.prefixPath ? launchInfo.prefixPath : "") wrapMode: Text.WrapAnywhere } + + QQC2.Label { + Kirigami.FormData.label: i18n("Steam install path:") + text: launchInfo && launchInfo.resolvedSteamInstallPath ? launchInfo.resolvedSteamInstallPath : i18n("Not applicable") + wrapMode: Text.WrapAnywhere + } } } } diff --git a/src/qml/GameEditDialog.qml b/src/qml/GameEditDialog.qml index 4729aa4..bf2a822 100644 --- a/src/qml/GameEditDialog.qml +++ b/src/qml/GameEditDialog.qml @@ -19,6 +19,10 @@ Kirigami.Dialog { id: envModel } + ListModel { + id: runnerChoicesModel + } + signal gameCreated(var game) signal gameUpdated(var game) @@ -35,14 +39,35 @@ Kirigami.Dialog { Kirigami.Action { text: isEditing ? i18n("Apply") : i18n("Add") icon.name: isEditing ? "dialog-ok-apply" : "list-add" - enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (runnerCombo.currentIndex !== 3 || runnerPathField.text.trim() !== "") + enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (dialog.currentRunnerChoiceKind() !== "custom" || runnerPathField.text.trim() !== "") onTriggered: { - let runnerValue = dialog.runnerFromIndex(runnerCombo.currentIndex) + let choice = dialog.currentRunnerChoice() + let runnerKind = choice && choice.kind ? String(choice.kind) : "native" + let runnerType = choice && choice.runnerType ? String(choice.runnerType) : (choice && choice.runner ? String(choice.runner) : "") + + let runnerValue = choice && choice.runner ? String(choice.runner) : "" + let runnerIdValue = choice && choice.runnerId ? String(choice.runnerId) : "" + let runnerPathValue = runnerPathField.text.trim() let prefixPathValue = prefixPathField.text.trim() - if (runnerValue === "") { + + if (runnerKind === "native") { + runnerValue = "" + runnerIdValue = "" runnerPathValue = "" prefixPathValue = "" + } else if (runnerKind === "inventory") { + runnerValue = "" + runnerPathValue = "" + } else if (runnerKind === "wine" || runnerKind === "proton") { + runnerIdValue = "" + } else if (runnerKind === "custom") { + runnerIdValue = "" + prefixPathValue = "" + } + + if (runnerType !== "wine" && runnerType !== "proton") { + prefixPathValue = "" } if (isEditing) { game.name = nameField.text.trim() @@ -51,6 +76,7 @@ Kirigami.Dialog { game.workingDirectory = workingDirField.text.trim() game.launchEnv = dialog.envModelToMap() game.launchRunner = runnerValue + game.launchRunnerId = runnerIdValue game.launchRunnerPath = runnerPathValue game.launchPrefixPath = prefixPathValue if (selectedCoverPath !== "") { @@ -65,6 +91,7 @@ Kirigami.Dialog { newGame.workingDirectory = workingDirField.text.trim() newGame.launchEnv = dialog.envModelToMap() newGame.launchRunner = runnerValue + newGame.launchRunnerId = runnerIdValue newGame.launchRunnerPath = runnerPathValue newGame.launchPrefixPath = prefixPathValue if (selectedCoverPath !== "") { @@ -86,7 +113,24 @@ Kirigami.Dialog { property string selectedCoverPath: "" - readonly property bool anyMenuOpen: runnerCombo && runnerCombo.popup && runnerCombo.popup.visible + readonly property bool anyConfirmOpen: !!(deletePrefixConfirmDialog && deletePrefixConfirmDialog.opened) + + readonly property bool anyMenuOpen: !!(runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) + + function currentConfirmDialog() { + if (deletePrefixConfirmDialog && deletePrefixConfirmDialog.opened) return deletePrefixConfirmDialog + return null + } + + function closeCurrentConfirmDialog() { + let d = currentConfirmDialog() + if (!d) return + if (typeof d.reject === "function") { + d.reject() + } else { + d.close() + } + } function closeCurrentMenu() { if (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) { @@ -181,16 +225,84 @@ Kirigami.Dialog { return 0 } + function currentRunnerChoice() { + if (!runnerChoicesModel || runnerChoicesModel.count <= 0) return null + let idx = runnerCombo ? runnerCombo.currentIndex : 0 + if (idx < 0 || idx >= runnerChoicesModel.count) return runnerChoicesModel.get(0) + return runnerChoicesModel.get(idx) + } + + function currentRunnerChoiceKind() { + let c = dialog.currentRunnerChoice() + if (!c || !c.kind) return "native" + return String(c.kind) + } + + function currentRunnerChoiceRunnerType() { + let c = dialog.currentRunnerChoice() + if (!c) return "" + if (c.runnerType) return String(c.runnerType) + if (c.runner) return String(c.runner) + return "" + } + + function rebuildRunnerChoices() { + runnerChoicesModel.clear() + + runnerChoicesModel.append({ kind: "native", label: i18n("Native"), runner: "", runnerId: "", runnerType: "" }) + runnerChoicesModel.append({ kind: "wine", label: i18n("Wine (system)"), runner: "wine", runnerId: "", runnerType: "wine" }) + runnerChoicesModel.append({ kind: "proton", label: i18n("Proton (auto)"), runner: "proton", runnerId: "", runnerType: "proton" }) + + let runners = (App.runnerManager && App.runnerManager.runners) ? App.runnerManager.runners : [] + for (let i = 0; i < runners.length; i++) { + let r = runners[i] + if (!r || !r.id) continue + let name = r.name ? String(r.name) : String(r.id) + let source = r.source ? String(r.source) : "" + let type = r.type ? String(r.type) : "" + let label = name + if (type !== "" || source !== "") { + label = name + " (" + type + (source !== "" ? (" · " + source) : "") + ")" + } + runnerChoicesModel.append({ kind: "inventory", label: label, runner: "", runnerId: String(r.id), runnerType: type }) + } + + runnerChoicesModel.append({ kind: "custom", label: i18n("Custom runner path"), runner: "custom", runnerId: "", runnerType: "custom" }) + } + + function indexForRunnerId(runnerId) { + let id = (runnerId || "").trim() + if (id === "") return 0 + for (let i = 0; i < runnerChoicesModel.count; i++) { + let row = runnerChoicesModel.get(i) + if (row.kind === "inventory" && String(row.runnerId || "") === id) return i + } + return 0 + } + + function indexForRunner(runner) { + let r = (runner || "").trim().toLowerCase() + if (r === "wine") return 1 + if (r === "proton") return 2 + if (r === "custom") return Math.max(0, runnerChoicesModel.count - 1) + return 0 + } + function loadFields() { selectedCoverPath = "" envModel.clear() + dialog.rebuildRunnerChoices() if (isEditing && game) { nameField.text = game.name || "" developerField.text = game.developer || "" executableField.text = game.launchCommand || "" workingDirField.text = game.workingDirectory || "" - runnerCombo.currentIndex = dialog.runnerToIndex(game.launchRunner) + let idx = dialog.indexForRunnerId(game.launchRunnerId) + if (idx === 0) { + idx = dialog.indexForRunner(game.launchRunner) + } + runnerCombo.currentIndex = idx runnerPathField.text = game.launchRunnerPath || "" prefixPathField.text = game.launchPrefixPath || "" @@ -213,6 +325,66 @@ Kirigami.Dialog { } } + Connections { + target: App.runnerManager + function onRunnersChanged() { + if (!dialog.opened) return + + let desiredRunnerId = "" + if (isEditing && game) { + desiredRunnerId = String(game.launchRunnerId || "").trim() + } + + let current = dialog.currentRunnerChoice() + let currentRunnerId = current && current.runnerId ? String(current.runnerId) : "" + let currentRunner = current && current.runner ? String(current.runner) : "" + + dialog.rebuildRunnerChoices() + + if (desiredRunnerId !== "") { + runnerCombo.currentIndex = dialog.indexForRunnerId(desiredRunnerId) + } else if (currentRunnerId !== "") { + runnerCombo.currentIndex = dialog.indexForRunnerId(currentRunnerId) + } else { + runnerCombo.currentIndex = dialog.indexForRunner(currentRunner) + } + } + + function onPrefixEnsured(gameId, prefixPath) { + if (!dialog.opened) return + if (!isEditing || !game) return + if (String(gameId) !== String(game.id)) return + prefixPathField.text = String(prefixPath || "") + } + + function onPrefixDeleted(gameId, prefixPath) { + if (!dialog.opened) return + if (!isEditing || !game) return + if (String(gameId) !== String(game.id)) return + prefixPathField.text = "" + } + } + + function effectivePrefixPath() { + let p = prefixPathField.text.trim() + if (p !== "") return p + if (!isEditing || !game) return "" + return StandardPaths.writableLocation(StandardPaths.AppDataLocation) + "/prefixes/" + game.id + } + + function toFileUrl(path) { + let p = String(path || "").trim() + if (p === "") return "" + if (p.startsWith("~/")) { + p = StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + p.substring(2) + } + if (p.startsWith("file:")) return p + if (p.startsWith("/")) { + return "file:///" + encodeURI(p.substring(1)) + } + return "file:///" + encodeURI(p) + } + Connections { target: GamepadManager function onNavigateUp() { @@ -325,26 +497,11 @@ Kirigami.Dialog { title: i18n("Compatibility") } - Item { - Layout.fillWidth: true - Layout.margins: Kirigami.Units.largeSpacing - - RowLayout { - anchors.left: parent.left - anchors.right: parent.right - spacing: Kirigami.Units.largeSpacing - - QQC2.Label { - text: i18n("Runner") - Layout.alignment: Qt.AlignVCenter - } - - QQC2.ComboBox { - id: runnerCombo - Layout.fillWidth: true - model: [i18n("Native"), i18n("Wine"), i18n("Proton"), i18n("Custom")] - } - } + FormCard.FormComboBoxDelegate { + id: runnerCombo + text: i18n("Runner") + model: runnerChoicesModel + textRole: "label" } FormCard.FormDelegateSeparator {} @@ -353,8 +510,8 @@ Kirigami.Dialog { id: runnerPathField label: i18n("Runner Path") text: "" - placeholderText: runnerCombo.currentIndex === 2 ? i18n("Auto-detect Proton") : (runnerCombo.currentIndex === 1 ? i18n("Use system Wine") : i18n("Required for Custom")) - enabled: runnerCombo.currentIndex !== 0 + placeholderText: dialog.currentRunnerChoiceKind() === "proton" ? i18n("Auto-detect Proton") : (dialog.currentRunnerChoiceKind() === "wine" ? i18n("Use system Wine") : i18n("Required for Custom")) + enabled: dialog.currentRunnerChoiceKind() === "custom" || dialog.currentRunnerChoiceKind() === "wine" || dialog.currentRunnerChoiceKind() === "proton" } FormCard.FormDelegateSeparator {} @@ -362,7 +519,7 @@ Kirigami.Dialog { FormCard.FormButtonDelegate { text: i18n("Browse Runner...") icon.name: "document-open" - enabled: runnerCombo.currentIndex !== 0 + enabled: dialog.currentRunnerChoiceKind() === "custom" || dialog.currentRunnerChoiceKind() === "wine" || dialog.currentRunnerChoiceKind() === "proton" onClicked: runnerFileDialog.open() } @@ -373,7 +530,7 @@ Kirigami.Dialog { label: i18n("Prefix Path") text: "" placeholderText: i18n("Default (AppDataLocation/prefixes/)") - enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2 + enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton" } FormCard.FormDelegateSeparator {} @@ -381,9 +538,52 @@ Kirigami.Dialog { FormCard.FormButtonDelegate { text: i18n("Browse Prefix...") icon.name: "document-open-folder" - enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2 + enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton" onClicked: prefixFolderDialog.open() } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Open Prefix Folder") + icon.name: "folder-open" + enabled: isEditing && (dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton") + onClicked: { + let p = dialog.effectivePrefixPath() + if (p === "") return + Qt.openUrlExternally(dialog.toFileUrl(p)) + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Delete Prefix") + icon.name: "edit-delete" + enabled: isEditing + && (dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton") + && (prefixPathField.text.trim() === "") + onClicked: deletePrefixConfirmDialog.open() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Create Prefix") + icon.name: "folder-new" + enabled: isEditing + && (dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton") + onClicked: App.runnerManager.ensurePrefix(game.id, dialog.currentRunnerChoiceRunnerType(), prefixPathField.text.trim()) + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Reset Prefix to Default") + icon.name: "edit-clear" + enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton" + onClicked: prefixPathField.text = "" + } } FormCard.FormCard { @@ -435,42 +635,37 @@ Kirigami.Dialog { onClicked: envModel.clear() } - Item { + ColumnLayout { Layout.fillWidth: true Layout.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - spacing: Kirigami.Units.smallSpacing + Repeater { + model: envModel - Repeater { - model: envModel + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing - RowLayout { + QQC2.TextField { Layout.fillWidth: true - spacing: Kirigami.Units.smallSpacing + Layout.preferredWidth: 1 + placeholderText: i18n("KEY") + text: key + onTextChanged: envModel.setProperty(index, "key", text) + } - QQC2.TextField { - Layout.fillWidth: true - Layout.preferredWidth: 1 - placeholderText: i18n("KEY") - text: key - onTextChanged: envModel.setProperty(index, "key", text) - } + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 2 + placeholderText: i18n("VALUE") + text: value + onTextChanged: envModel.setProperty(index, "value", text) + } - QQC2.TextField { - Layout.fillWidth: true - Layout.preferredWidth: 2 - placeholderText: i18n("VALUE") - text: value - onTextChanged: envModel.setProperty(index, "value", text) - } - - QQC2.ToolButton { - icon.name: "list-remove" - onClicked: envModel.remove(index) - } + QQC2.ToolButton { + icon.name: "list-remove" + onClicked: envModel.remove(index) } } } @@ -630,9 +825,35 @@ Kirigami.Dialog { } onOpened: { + App.runnerManager.refreshRunners() loadFields() nameField.forceActiveFocus() } onGameChanged: loadFields() + + Kirigami.PromptDialog { + id: deletePrefixConfirmDialog + title: i18n("Delete Prefix") + subtitle: (isEditing && game) + ? i18n("Delete the prefix for '%1'? This will remove the entire prefix directory.", game.name) + : i18n("Delete this prefix? This will remove the entire prefix directory.") + standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No + onAccepted: { + if (!isEditing || !game) return + App.runnerManager.deletePrefix(game.id, prefixPathField.text.trim()) + } + onOpened: { + Qt.callLater(function() { + if (typeof deletePrefixConfirmDialog.standardButton === "function") { + let noButton = deletePrefixConfirmDialog.standardButton(Kirigami.Dialog.No) + if (noButton) { + noButton.forceActiveFocus() + return + } + } + deletePrefixConfirmDialog.forceActiveFocus() + }) + } + } } diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 7621b1b..245798c 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -102,6 +102,7 @@ Kirigami.ApplicationWindow { function currentConfirmDialog() { if (removeGameConfirmDialog.opened) return removeGameConfirmDialog + if (gameEditDialog.visible && gameEditDialog.anyConfirmOpen) return gameEditDialog.currentConfirmDialog() let layerContent = root.settingsLayerContentItem() if (settingsLayerOpen && layerContent && layerContent.anyConfirmOpen) return layerContent.currentConfirmDialog() if (settingsSheet.opened && settingsContent.anyConfirmOpen) return settingsContent.currentConfirmDialog() @@ -177,6 +178,10 @@ Kirigami.ApplicationWindow { return true } if (gameEditDialog.visible) { + if (gameEditDialog.anyConfirmOpen) { + gameEditDialog.closeCurrentConfirmDialog() + return true + } if (gameEditDialog.anyMenuOpen) { gameEditDialog.closeCurrentMenu() return true @@ -2041,6 +2046,17 @@ Kirigami.ApplicationWindow { } } + Connections { + target: App.runnerManager + + function onLastErrorChanged() { + if (!App.runnerManager) return + let msg = String(App.runnerManager.lastError || "") + if (msg.trim() === "") return + showPassiveNotification(i18n("Runner error: %1", msg), "long") + } + } + Component.onCompleted: { if (!root.isMobile) { const savedW = App.config.windowWidth diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index 419747b..cf8730a 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -13,6 +13,10 @@ ColumnLayout { id: settingsPage spacing: Kirigami.Units.mediumSpacing + property string runnerInstallUrl: "" + property string runnerInstallSha256: "" + property string runnerInstallName: "" + function focusFirstControl() { showPlatformBadgesDelegate.forceActiveFocus() } @@ -546,6 +550,165 @@ FormCard.FormHeader { } } + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Runners") + } + + FormCard.FormCard { + Layout.fillWidth: true + + FormCard.FormTextFieldDelegate { + label: i18n("Archive URL or local path") + text: settingsPage.runnerInstallUrl + placeholderText: i18n("https://... or /home/.../file.tar.gz") + enabled: !App.runnerManager.busy + onTextChanged: settingsPage.runnerInstallUrl = text + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + label: i18n("SHA256/SHA512 (optional)") + text: settingsPage.runnerInstallSha256 + placeholderText: i18n("Leave empty to skip verification") + enabled: !App.runnerManager.busy + onTextChanged: settingsPage.runnerInstallSha256 = text + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + label: i18n("Name (optional)") + text: settingsPage.runnerInstallName + placeholderText: i18n("Proton-GE") + enabled: !App.runnerManager.busy + onTextChanged: settingsPage.runnerInstallName = text + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Install from URL") + description: App.runnerManager.busy ? App.runnerManager.status : i18n("Download and install runner") + icon.name: "download" + enabled: settingsPage.runnerInstallUrl.trim() !== "" && !App.runnerManager.busy + onClicked: App.runnerManager.installRunnerFromUrl(settingsPage.runnerInstallUrl, settingsPage.runnerInstallSha256, settingsPage.runnerInstallName) + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Cancel install") + icon.name: "dialog-cancel" + enabled: App.runnerManager.busy + onClicked: App.runnerManager.cancelCurrentInstall() + } + + FormCard.FormDelegateSeparator {} + + Item { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + + ColumnLayout { + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.smallSpacing + + QQC2.Label { + Layout.fillWidth: true + text: App.runnerManager.status + wrapMode: Text.WordWrap + visible: App.runnerManager.status !== "" + } + + QQC2.Label { + Layout.fillWidth: true + text: App.runnerManager.lastError + wrapMode: Text.WordWrap + visible: App.runnerManager.lastError !== "" + color: Kirigami.Theme.negativeTextColor + } + + QQC2.ProgressBar { + Layout.fillWidth: true + from: 0 + to: App.runnerManager.totalBytes > 0 ? App.runnerManager.totalBytes : 1 + value: App.runnerManager.receivedBytes + indeterminate: App.runnerManager.busy && App.runnerManager.totalBytes <= 0 + visible: App.runnerManager.busy + } + } + } + } + + FormCard.FormHeader { + Layout.topMargin: Kirigami.Units.mediumSpacing + Layout.fillWidth: true + title: i18n("Installed Runners") + } + + FormCard.FormCard { + Layout.fillWidth: true + + FormCard.FormButtonDelegate { + text: i18n("Refresh") + icon.name: "view-refresh" + enabled: !App.runnerManager.busy + onClicked: App.runnerManager.refreshRunners() + } + + FormCard.FormDelegateSeparator {} + + Repeater { + model: App.runnerManager.runners + + ColumnLayout { + Layout.fillWidth: true + + readonly property var runner: modelData + readonly property string runnerId: (runner && runner.id) ? runner.id : "" + readonly property string runnerName: (runner && runner.name) ? runner.name : runnerId + readonly property string runnerType: (runner && runner.type) ? runner.type : "" + readonly property string runnerSource: (runner && runner.source) ? runner.source : "" + readonly property string runnerPath: (runner && runner.path) ? runner.path : "" + + FormCard.FormTextDelegate { + text: runnerName + description: (runnerType !== "" ? (runnerType + " · ") : "") + (runnerSource !== "" ? runnerSource : "") + textItem.wrapMode: Text.WordWrap + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextDelegate { + text: runnerPath + textItem.wrapMode: Text.WrapAnywhere + textItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Uninstall") + icon.name: "edit-delete" + enabled: runnerSource === "installed" && runnerId !== "" && !App.runnerManager.busy + onClicked: App.runnerManager.uninstallRunner(runnerId) + } + + FormCard.FormDelegateSeparator {} + } + } + + FormCard.FormTextDelegate { + visible: App.runnerManager.runners.length === 0 + text: i18n("No runners found") + textItem.font: Kirigami.Theme.smallFont + } + } + FormCard.FormHeader { Layout.topMargin: Kirigami.Units.mediumSpacing Layout.fillWidth: true