2026-01-18 12:13:07 +00:00
// 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
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 )
2026-01-20 09:59:29 +00:00
height: Math . min ( parent . height - Kirigami . Units . gridUnit * 4 , implicitHeight )
x: Math . round ( ( parent . width - width ) / 2 )
y: Math . round ( ( parent . height - height ) / 2 )
2026-01-18 12:13:07 +00:00
customFooterActions: [
Kirigami . Action {
text: isEditing ? i18n ( "Apply" ) : i18n ( "Add" )
icon.name: isEditing ? "dialog-ok-apply" : "list-add"
enabled: nameField . text . trim ( ) !== "" && executableField . text . trim ( ) !== ""
onTriggered: {
if ( isEditing ) {
game . name = nameField . text . trim ( )
game . developer = developerField . text . trim ( )
game . launchCommand = executableField . text . trim ( )
game . workingDirectory = workingDirField . text . trim ( )
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 ( )
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: ""
2026-01-24 12:27:30 +00:00
function isDescendant ( item , ancestor ) {
let p = item
while ( p ) {
if ( p === ancestor || ( ancestor . contentItem && p === ancestor . contentItem ) ) 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 . hasOwnProperty ( "checked" ) ) {
item . checked = ! item . checked
return
}
if ( typeof item . clicked === "function" ) {
item . clicked ( )
return
}
}
2026-01-20 09:59:29 +00:00
function loadFields ( ) {
selectedCoverPath = ""
if ( isEditing && game ) {
nameField . text = game . name || ""
developerField . text = game . developer || ""
executableField . text = game . launchCommand || ""
workingDirField . text = game . workingDirectory || ""
} else {
nameField . text = ""
developerField . text = ""
executableField . text = ""
workingDirField . text = ""
}
}
2026-01-24 12:27:30 +00:00
Connections {
target: GamepadManager
function onNavigateUp ( ) {
if ( ! dialog . opened ) return
let w = applicationWindow ( )
if ( ! w || ! w . activeFocusItem ) return
if ( ! dialog . isDescendant ( w . activeFocusItem , dialog ) ) return
dialog . focusNextInChain ( false )
}
function onNavigateDown ( ) {
if ( ! dialog . opened ) return
let w = applicationWindow ( )
if ( ! w || ! w . activeFocusItem ) return
if ( ! dialog . isDescendant ( w . activeFocusItem , dialog ) ) return
dialog . focusNextInChain ( true )
}
function onNavigateLeft ( ) {
if ( ! dialog . opened ) return
let w = applicationWindow ( )
if ( ! w || ! w . activeFocusItem ) return
if ( ! dialog . isDescendant ( w . activeFocusItem , dialog ) ) return
dialog . focusNextInChain ( false )
}
function onNavigateRight ( ) {
if ( ! dialog . opened ) return
let w = applicationWindow ( )
if ( ! w || ! w . activeFocusItem ) return
if ( ! dialog . isDescendant ( w . activeFocusItem , dialog ) ) return
dialog . focusNextInChain ( true )
}
function onSelectPressed ( ) {
if ( ! dialog . opened ) return
let w = applicationWindow ( )
if ( ! w || ! w . activeFocusItem ) return
if ( ! dialog . isDescendant ( w . activeFocusItem , dialog ) ) return
dialog . activateFocused ( )
}
}
2026-01-18 12:13:07 +00:00
ColumnLayout {
spacing: 0
FormCard . FormCard {
Layout.fillWidth: true
FormCard . FormTextFieldDelegate {
id: nameField
label: i18n ( "Name" )
2026-01-20 09:59:29 +00:00
text: ""
2026-01-18 12:13:07 +00:00
placeholderText: i18n ( "Game title" )
onAccepted: developerField . forceActiveFocus ( )
}
FormCard . FormDelegateSeparator { }
FormCard . FormTextFieldDelegate {
id: developerField
label: i18n ( "Developer" )
2026-01-20 09:59:29 +00:00
text: ""
2026-01-18 12:13:07 +00:00
placeholderText: i18n ( "Optional" )
onAccepted: executableField . forceActiveFocus ( )
}
FormCard . FormDelegateSeparator { }
FormCard . FormTextFieldDelegate {
id: executableField
label: i18n ( "Executable" )
2026-01-20 09:59:29 +00:00
text: ""
2026-01-18 12:13:07 +00:00
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" )
2026-01-20 09:59:29 +00:00
text: ""
2026-01-18 12:13:07 +00:00
placeholderText: i18n ( "Optional" )
}
}
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://" , "" )
}
}
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: {
2026-01-20 09:59:29 +00:00
loadFields ( )
2026-01-18 12:13:07 +00:00
nameField . forceActiveFocus ( )
}
2026-01-20 09:59:29 +00:00
onGameChanged: loadFields ( )
2026-01-18 12:13:07 +00:00
}