a-la-karte/src/qml/GameEditDialog.qml

718 lines
26 KiB
QML
Raw Normal View History

// 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
property string editProfileId: "default"
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()
if (dialog.editProfileId === "couch") {
game.setLaunchProfileConfig("couch", {
"runner": runnerValue,
"runnerPath": runnerPathValue,
"prefixPath": prefixPathValue,
"env": dialog.envModelToMap()
})
} else {
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) || (profileCombo && profileCombo.popup && profileCombo.popup.visible)
function closeCurrentMenu() {
if (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) {
runnerCombo.popup.close()
}
if (profileCombo && profileCombo.popup && profileCombo.popup.visible) {
profileCombo.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 profileIdFromIndex(idx) {
if (idx === 1) return "couch"
return "default"
}
function profileIndexFromId(profileId) {
if ((profileId || "").trim() === "couch") return 1
return 0
}
function loadProfileFields() {
envModel.clear()
if (!isEditing || !game) {
runnerCombo.currentIndex = 0
runnerPathField.text = ""
prefixPathField.text = ""
return
}
if (dialog.editProfileId === "couch") {
let cfg = game.launchProfileConfig("couch") || ({})
runnerCombo.currentIndex = dialog.runnerToIndex(cfg.runner)
runnerPathField.text = cfg.runnerPath || ""
prefixPathField.text = cfg.prefixPath || ""
let env = cfg.env || ({})
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 {
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]) })
}
}
}
function loadFields() {
selectedCoverPath = ""
if (isEditing && game) {
nameField.text = game.name || ""
developerField.text = game.developer || ""
executableField.text = game.launchCommand || ""
workingDirField.text = game.workingDirectory || ""
dialog.editProfileId = game.activeLaunchProfile === "couch" ? "couch" : "default"
profileCombo.currentIndex = dialog.profileIndexFromId(dialog.editProfileId)
dialog.loadProfileFields()
} else {
nameField.text = ""
developerField.text = ""
executableField.text = ""
workingDirField.text = ""
dialog.editProfileId = "default"
profileCombo.currentIndex = 0
dialog.loadProfileFields()
}
}
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
visible: isEditing
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Profile")
Layout.alignment: Qt.AlignVCenter
}
QQC2.ComboBox {
id: profileCombo
Layout.fillWidth: true
model: [i18n("Default"), i18n("Couch")]
onCurrentIndexChanged: if (dialog.isEditing) {
dialog.editProfileId = dialog.profileIdFromIndex(currentIndex)
dialog.loadProfileFields()
}
}
}
}
FormCard.FormDelegateSeparator { visible: isEditing }
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/<gameId>)")
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()
}