// 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 } 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() !== "" && (runnerCombo.currentIndex !== 3 || runnerPathField.text.trim() !== "") onTriggered: { let runnerValue = dialog.runnerFromIndex(runnerCombo.currentIndex) let runnerPathValue = runnerPathField.text.trim() let prefixPathValue = prefixPathField.text.trim() if (runnerValue === "") { runnerPathValue = "" 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.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.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 anyMenuOpen: runnerCombo && runnerCombo.popup && runnerCombo.popup.visible 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 loadFields() { selectedCoverPath = "" envModel.clear() 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) 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: 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") } 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.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { 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 } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Browse Runner...") icon.name: "document-open" enabled: runnerCombo.currentIndex !== 0 onClicked: runnerFileDialog.open() } FormCard.FormDelegateSeparator {} FormCard.FormTextFieldDelegate { id: prefixPathField label: i18n("Prefix Path") text: "" placeholderText: i18n("Default (AppDataLocation/prefixes/)") enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2 } FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18n("Browse Prefix...") icon.name: "document-open-folder" enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2 onClicked: prefixFolderDialog.open() } } 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() } Item { Layout.fillWidth: true Layout.margins: Kirigami.Units.largeSpacing ColumnLayout { anchors.left: parent.left anchors.right: parent.right 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: { loadFields() nameField.forceActiveFocus() } onGameChanged: loadFields() }