// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2026 A-La-Karte Contributors import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts import QtQuick.Dialogs import org.kde.kirigami as Kirigami import org.kde.kirigamiaddons.formcard as FormCard import org.kde.alakarte Kirigami.Dialog { id: dialog property var game: null property bool isEditing: game !== null ListModel { id: envModel } ListModel { id: runnerChoicesModel } signal gameCreated(var game) signal gameUpdated(var game) title: isEditing ? i18n("Edit Game") : i18n("Add New Game") standardButtons: Kirigami.Dialog.NoButton width: Math.min(parent.width - Kirigami.Units.gridUnit * 4, Kirigami.Units.gridUnit * 30) height: Math.min(parent.height - Kirigami.Units.gridUnit * 4, implicitHeight) x: Math.round((parent.width - width) / 2) y: Math.round((parent.height - height) / 2) customFooterActions: [ Kirigami.Action { text: isEditing ? i18n("Apply") : i18n("Add") icon.name: isEditing ? "dialog-ok-apply" : "list-add" enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (dialog.currentRunnerChoiceKind() !== "custom" || runnerPathField.text.trim() !== "") onTriggered: { 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 (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() game.developer = developerField.text.trim() game.launchCommand = executableField.text.trim() game.workingDirectory = workingDirField.text.trim() game.launchEnv = dialog.envModelToMap() game.launchRunner = runnerValue game.launchRunnerId = runnerIdValue game.launchRunnerPath = runnerPathValue game.launchPrefixPath = prefixPathValue if (selectedCoverPath !== "") { App.setCoverFromFile(game, selectedCoverPath) } App.saveLibrary() gameUpdated(game) } else { let newGame = App.createGame(nameField.text.trim(), executableField.text.trim()) if (newGame) { newGame.developer = developerField.text.trim() newGame.workingDirectory = workingDirField.text.trim() newGame.launchEnv = dialog.envModelToMap() newGame.launchRunner = runnerValue newGame.launchRunnerId = runnerIdValue newGame.launchRunnerPath = runnerPathValue newGame.launchPrefixPath = prefixPathValue if (selectedCoverPath !== "") { App.setCoverFromFile(newGame, selectedCoverPath) } App.saveLibrary() gameCreated(newGame) } } dialog.close() } }, Kirigami.Action { text: i18n("Cancel") icon.name: "dialog-cancel" onTriggered: dialog.close() } ] property string selectedCoverPath: "" 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) { runnerCombo.popup.close() } } function isDescendant(item, ancestor) { let p = item while (p) { if (p === ancestor || (ancestor.contentItem && p === ancestor.contentItem)) return true if (p.visualParent !== undefined && p.visualParent !== null) { if (dialog.isDescendant(p.visualParent, ancestor)) return true } else if (p.popup !== undefined && p.popup !== null && p.popup.visualParent !== undefined && p.popup.visualParent !== null) { if (dialog.isDescendant(p.popup.visualParent, ancestor)) return true } p = p.parent } return false } function focusNextInChain(forward) { let w = applicationWindow() if (!w || !w.activeFocusItem) return let next = w.activeFocusItem for (let i = 0; i < 50; i++) { next = next.nextItemInFocusChain(forward) if (!next) return if (dialog.isDescendant(next, dialog)) { next.forceActiveFocus() return } } } function activateFocused() { let w = applicationWindow() if (!w || !w.activeFocusItem) return let item = w.activeFocusItem if (typeof item.toggle === "function") { item.toggle() return } if (item.checkable !== undefined && item.checkable && item.checked !== undefined) { item.checked = !item.checked return } if (typeof item.clicked === "function") { item.clicked() return } } function envModelToMap() { let result = ({}) for (let i = 0; i < envModel.count; i++) { let row = envModel.get(i) let k = (row.key || "").trim() if (k === "") continue if (k.indexOf("=") !== -1) continue result[k] = String(row.value || "") } return result } function setEnvVar(key, value) { let k = (key || "").trim() if (k === "") return for (let i = 0; i < envModel.count; i++) { let row = envModel.get(i) if ((row.key || "").trim() === k) { envModel.setProperty(i, "value", String(value || "")) return } } envModel.append({ key: k, value: String(value || "") }) } function runnerFromIndex(idx) { if (idx === 1) return "wine" if (idx === 2) return "proton" if (idx === 3) return "custom" return "" } function runnerToIndex(runner) { let r = (runner || "").trim().toLowerCase() if (r === "wine") return 1 if (r === "proton") return 2 if (r === "custom") return 3 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 || "" 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 || "" let env = game.launchEnv || ({}) let keys = Object.keys(env) keys.sort() for (let i = 0; i < keys.length; i++) { let k = keys[i] envModel.append({ key: k, value: String(env[k]) }) } } else { nameField.text = "" developerField.text = "" executableField.text = "" workingDirField.text = "" runnerCombo.currentIndex = 0 runnerPathField.text = "" prefixPathField.text = "" } } 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() { let app = applicationWindow() if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return if (!dialog.opened) return let w = applicationWindow() if (!w || !w.activeFocusItem) return if (!dialog.isDescendant(w.activeFocusItem, dialog)) return dialog.focusNextInChain(false) } function onNavigateDown() { let app = applicationWindow() if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return if (!dialog.opened) return let w = applicationWindow() if (!w || !w.activeFocusItem) return if (!dialog.isDescendant(w.activeFocusItem, dialog)) return dialog.focusNextInChain(true) } function onNavigateLeft() { let app = applicationWindow() if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return if (!dialog.opened) return let w = applicationWindow() if (!w || !w.activeFocusItem) return if (!dialog.isDescendant(w.activeFocusItem, dialog)) return dialog.focusNextInChain(false) } function onNavigateRight() { let app = applicationWindow() if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return if (!dialog.opened) return let w = applicationWindow() if (!w || !w.activeFocusItem) return if (!dialog.isDescendant(w.activeFocusItem, dialog)) return dialog.focusNextInChain(true) } function onSelectPressed() { let app = applicationWindow() if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return if (!dialog.opened) return let w = applicationWindow() if (!w || !w.activeFocusItem) return if (!dialog.isDescendant(w.activeFocusItem, dialog)) return dialog.activateFocused() } } ColumnLayout { spacing: 0 FormCard.FormCard { Layout.fillWidth: true FormCard.FormTextFieldDelegate { id: nameField label: i18n("Name") text: "" placeholderText: i18n("Game title") onAccepted: developerField.forceActiveFocus() } FormCard.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { id: developerField label: i18n("Developer") text: "" placeholderText: i18n("Optional") onAccepted: executableField.forceActiveFocus() } FormCard.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { id: executableField label: i18n("Executable") text: "" placeholderText: i18n("/path/to/game or command") onAccepted: if (nameField.text.trim() !== "" && text.trim() !== "") { dialog.customFooterActions[0].trigger() } } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Browse...") icon.name: "document-open" onClicked: fileDialog.open() } FormCard.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { id: workingDirField label: i18n("Working Directory") text: "" placeholderText: i18n("Optional") } FormCard.FormDelegateSeparator {} FormCard.FormCard { Layout.fillWidth: true Layout.topMargin: Kirigami.Units.largeSpacing FormCard.FormHeader { title: i18n("Compatibility") } FormCard.FormComboBoxDelegate { id: runnerCombo text: i18n("Runner") model: runnerChoicesModel textRole: "label" } FormCard.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { id: runnerPathField label: i18n("Runner Path") text: "" 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 {} FormCard.FormButtonDelegate { text: i18n("Browse Runner...") icon.name: "document-open" enabled: dialog.currentRunnerChoiceKind() === "custom" || dialog.currentRunnerChoiceKind() === "wine" || dialog.currentRunnerChoiceKind() === "proton" onClicked: runnerFileDialog.open() } FormCard.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { id: prefixPathField label: i18n("Prefix Path") text: "" placeholderText: i18n("Default (AppDataLocation/prefixes/)") enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton" } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Browse Prefix...") icon.name: "document-open-folder" 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 { Layout.fillWidth: true Layout.topMargin: Kirigami.Units.largeSpacing FormCard.FormHeader { title: i18n("Environment Variables") } FormCard.FormTextDelegate { text: i18n("These variables are applied only when launching this game from A-La-Karte.") textItem.wrapMode: Text.WordWrap textItem.font: Kirigami.Theme.smallFont } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Add Variable") icon.name: "list-add" onClicked: envModel.append({ key: "", value: "" }) } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Preset: MangoHud") description: i18n("Sets MANGOHUD=1") icon.name: "games-hint" onClicked: dialog.setEnvVar("MANGOHUD", "1") } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Preset: GameMode") description: i18n("Sets GAMEMODERUN=1") icon.name: "speedometer" onClicked: dialog.setEnvVar("GAMEMODERUN", "1") } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Clear Variables") icon.name: "edit-clear" enabled: envModel.count > 0 onClicked: envModel.clear() } ColumnLayout { Layout.fillWidth: true Layout.margins: Kirigami.Units.largeSpacing spacing: Kirigami.Units.smallSpacing Repeater { model: envModel RowLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing 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.ToolButton { icon.name: "list-remove" onClicked: envModel.remove(index) } } } } } } FormCard.FormCard { Layout.fillWidth: true Layout.topMargin: Kirigami.Units.largeSpacing FormCard.FormHeader { title: i18n("Cover Art") } Item { Layout.fillWidth: true Layout.preferredHeight: Kirigami.Units.gridUnit * 8 Layout.margins: Kirigami.Units.largeSpacing RowLayout { anchors.fill: parent spacing: Kirigami.Units.largeSpacing Rectangle { Layout.preferredWidth: Kirigami.Units.gridUnit * 5 Layout.preferredHeight: Kirigami.Units.gridUnit * 7 radius: Kirigami.Units.smallSpacing color: Kirigami.Theme.alternateBackgroundColor border.color: Kirigami.Theme.disabledTextColor border.width: 1 Image { anchors.fill: parent anchors.margins: 2 source: selectedCoverPath !== "" ? "file://" + selectedCoverPath : (isEditing && game && game.coverUrl.toString() !== "" ? game.coverUrl : "") fillMode: Image.PreserveAspectCrop Kirigami.Icon { anchors.centerIn: parent source: "image-x-generic" width: Kirigami.Units.iconSizes.large height: width visible: parent.status !== Image.Ready opacity: 0.5 } } } ColumnLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing QQC2.Button { text: i18n("Choose Cover...") icon.name: "document-open" onClicked: coverDialog.open() } QQC2.Button { text: i18n("Fetch from SteamGridDB") icon.name: "download" enabled: App.steamGridDB.enabled && App.steamGridDB.apiKey.length > 0 && nameField.text.trim() !== "" && !App.steamGridDB.busy visible: App.steamGridDB.enabled onClicked: { if (isEditing && game) { App.steamGridDB.fetchCover(game) } } } QQC2.Button { text: i18n("Clear Cover") icon.name: "edit-clear" visible: selectedCoverPath !== "" || (isEditing && game && game.coverUrl.toString() !== "") onClicked: { selectedCoverPath = "" if (isEditing && game) { game.coverUrl = "" } } } } } } } FormCard.FormCard { Layout.fillWidth: true Layout.topMargin: Kirigami.Units.largeSpacing FormCard.FormHeader { title: i18n("Help") } FormCard.FormTextDelegate { text: i18n("To launch an executable, enter its full path:\n\"/path/to/game\"\n\nTo open a file with the default application:\nxdg-open \"/path/to/file\"\n\nIf the path contains spaces, wrap it in quotes.") textItem.wrapMode: Text.WordWrap textItem.font: Kirigami.Theme.smallFont } } } FileDialog { id: fileDialog title: i18n("Select Executable") fileMode: FileDialog.OpenFile onAccepted: { let path = selectedFile.toString().replace("file://", "") if (path.includes(" ")) { executableField.text = "\"" + path + "\"" } else { executableField.text = path } } } FileDialog { id: coverDialog title: i18n("Select Cover Image") fileMode: FileDialog.OpenFile nameFilters: [i18n("Image files (*.png *.jpg *.jpeg *.webp)"), i18n("All files (*)")] onAccepted: { selectedCoverPath = selectedFile.toString().replace("file://", "") } } FileDialog { id: runnerFileDialog title: i18n("Select Runner Executable") fileMode: FileDialog.OpenFile onAccepted: { runnerPathField.text = selectedFile.toString().replace("file://", "") } } FolderDialog { id: prefixFolderDialog title: i18n("Select Prefix Folder") onAccepted: { prefixPathField.text = selectedFolder.toString().replace("file://", "") } } function saveCoverImage(game) { if (selectedCoverPath === "") return // Copy cover to app data directory let coversPath = StandardPaths.writableLocation(StandardPaths.AppDataLocation) + "/covers" let fileName = game.id + ".jpg" let destPath = coversPath + "/" + fileName // Use App to copy the file and set cover URL App.setCoverFromFile(game, selectedCoverPath) } 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() }) } } }