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

836 lines
28 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 org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.alakarte
import "components"
Kirigami.ApplicationWindow {
id: root
title: i18n("A-La-Karte")
minimumWidth: Kirigami.Units.gridUnit * 25
minimumHeight: Kirigami.Units.gridUnit * 20
width: Kirigami.Units.gridUnit * 55
height: Kirigami.Units.gridUnit * 40
property var selectedGame: null
property string currentSource: "all"
property bool searchActive: false
function closeTopmost() {
if (gameEditDialog.visible) {
gameEditDialog.close()
return true
}
if (detailsSheet.opened) {
detailsSheet.close()
return true
}
if (importSheet.opened) {
importSheet.close()
return true
}
if (settingsSheet.opened) {
settingsSheet.close()
return true
}
if (sidebar.modal && sidebar.opened) {
sidebar.close()
return true
}
if (root.pageStack.layers.depth > 1) {
root.pageStack.layers.pop()
Qt.callLater(function() { libraryView.restoreFocus() })
return true
}
if (searchActive) {
searchActive = false
return true
}
return false
}
function canOpenSettings() {
return !gameEditDialog.visible
&& !detailsSheet.opened
&& !importSheet.opened
&& !sidebar.opened
&& root.pageStack.layers.depth <= 1
}
readonly property bool isMobile: Kirigami.Settings.isMobile
readonly property bool isTablet: Kirigami.Settings.tabletMode && !Kirigami.Settings.isMobile
readonly property bool isTouchDevice: Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
readonly property bool isNarrowScreen: width < Kirigami.Units.gridUnit * 30
readonly property int adaptiveCardSize: App.config.gridSize
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn
Shortcut {
sequence: "Ctrl+F"
onActivated: {
searchActive = true
Qt.callLater(function() { libraryView.focusSearch() })
}
}
Shortcut {
sequence: "Ctrl+I"
onActivated: importSheet.open()
}
Shortcut {
sequence: "Ctrl+N"
onActivated: {
gameEditDialog.game = null
gameEditDialog.open()
}
}
Shortcut {
sequence: StandardKey.Preferences
onActivated: settingsSheet.open()
}
Shortcut {
sequence: "Escape"
onActivated: root.closeTopmost()
}
Shortcut {
sequence: "F5"
onActivated: App.importAllGames()
}
Shortcut {
sequence: "Ctrl+H"
onActivated: {
if (root.currentSource === "hidden") {
root.currentSource = "all"
} else {
root.currentSource = "hidden"
}
}
}
Shortcut {
sequence: "Ctrl+D"
onActivated: {
if (root.selectedGame) {
detailsSheet.open()
}
}
}
Connections {
target: GamepadManager
function onBackPressed() {
root.closeTopmost()
}
function onMenuPressed() {
if (!settingsSheet.opened) {
if (root.canOpenSettings()) {
settingsSheet.open()
} else {
root.closeTopmost()
}
} else {
settingsSheet.close()
}
}
function onSearchPressed() {
searchActive = true
Qt.callLater(function() { libraryView.focusSearch() })
}
}
Connections {
target: root.pageStack.layers
function onDepthChanged() {
if (root.pageStack.layers.depth <= 1) {
Qt.callLater(function() { libraryView.restoreFocus() })
}
}
}
Component {
id: aboutPageComponent
FormCard.AboutPage {
title: i18n("About")
aboutData: ({
"displayName": i18n("A-La-Karte"),
"componentName": "alakarte",
"shortDescription": i18n("A unified game launcher for KDE Plasma"),
"homepage": "",
"bugAddress": "",
"version": Qt.application.version,
"otherText": "",
"authors": [
{
"name": i18n("A-La-Karte Contributors"),
"task": i18n("Contributors"),
"emailAddress": "",
"webAddress": "",
"ocsUsername": ""
}
],
"credits": [],
"translators": [],
"licenses": [
{
"name": i18n("GNU General Public License v3.0 or later"),
"text": "",
"spdx": "GPL-3.0-or-later"
}
],
"copyrightStatement": i18n("© 2026 A-La-Karte Contributors"),
"desktopFileName": "org.kde.alakarte"
})
}
}
globalDrawer: Kirigami.OverlayDrawer {
id: sidebar
edge: Qt.LeftEdge
modal: root.isMobile || root.isNarrowScreen
handleClosedIcon.source: "application-menu"
handleOpenIcon.source: "go-previous"
handleVisible: root.isMobile || root.isNarrowScreen
width: {
if (root.isMobile) return Math.min(root.width * 0.85, Kirigami.Units.gridUnit * 20)
if (root.isTablet) return Kirigami.Units.gridUnit * 16
return Kirigami.Units.gridUnit * 14
}
Binding {
target: sidebar
property: "drawerOpen"
value: true
when: !sidebar.modal
}
onOpened: sidebarView.focusList()
onClosed: Qt.callLater(function() { libraryView.restoreFocus() })
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: ColumnLayout {
spacing: 0
Kirigami.AbstractApplicationHeader {
Layout.fillWidth: true
contentItem: Item {
anchors.fill: parent
anchors.margins: Kirigami.Units.largeSpacing
RowLayout {
anchors.fill: parent
spacing: Kirigami.Units.mediumSpacing
IconWithResourceFallback {
primary: "org.kde.alakarte"
secondary: ""
resourceFallback: Qt.resolvedUrl("icons/app/org.kde.alakarte.svg")
mask: false
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
}
Kirigami.Heading {
text: i18n("A-La-Karte")
level: 2
Layout.fillWidth: true
}
}
}
}
SidebarView {
id: sidebarView
Layout.fillWidth: true
Layout.fillHeight: true
onSourceSelected: function(source) {
root.currentSource = source
if (sidebar.modal) {
sidebar.close()
Qt.callLater(function() { libraryView.restoreFocus() })
}
}
onSettingsRequested: {
if (sidebar.modal) {
sidebar.close()
}
settingsSheet.open()
}
onImportRequested: {
if (sidebar.modal) {
sidebar.close()
}
importSheet.open()
}
onAboutRequested: {
if (sidebar.modal) {
sidebar.close()
}
root.pageStack.layers.push(aboutPageComponent)
}
}
}
}
pageStack.initialPage: Kirigami.Page {
id: mainPage
title: sidebarView.currentSourceName
padding: Kirigami.Units.largeSpacing
actions: [
Kirigami.Action {
icon.name: "search"
text: i18n("Search")
checkable: true
checked: root.searchActive
onTriggered: {
root.searchActive = !root.searchActive
if (root.searchActive) {
Qt.callLater(function() { libraryView.focusSearch() })
} else {
libraryView.restoreFocus()
}
}
},
Kirigami.Action {
icon.name: "list-add"
text: i18n("Add Game")
onTriggered: {
gameEditDialog.game = null
gameEditDialog.open()
}
},
Kirigami.Action {
icon.name: "document-import"
text: i18n("Import Games")
onTriggered: importSheet.open()
},
Kirigami.Action {
icon.name: "configure"
text: i18n("Settings")
onTriggered: settingsSheet.open()
}
]
LibraryView {
id: libraryView
anchors.fill: parent
filterSource: root.currentSource
searchActive: root.searchActive
adaptiveCardSize: root.adaptiveCardSize
isTouchDevice: root.isTouchDevice
onGameSelected: function(game) {
root.selectedGame = game
detailsSheet.open()
}
onGameLaunched: function(game) {
App.launcher.launchGame(game)
}
}
}
GameDetailsSheet {
id: detailsSheet
game: root.selectedGame
onLaunchRequested: {
App.launcher.launchGame(root.selectedGame)
}
onEditRequested: {
detailsSheet.close()
gameEditDialog.game = root.selectedGame
gameEditDialog.open()
}
onRemoveRequested: {
if (root.selectedGame) {
let gameId = root.selectedGame.id
let gameName = root.selectedGame.name
App.removeGame(root.selectedGame)
detailsSheet.close()
root.selectedGame = null
showPassiveNotification(
i18n("%1 removed", gameName),
"long",
i18n("Undo"),
function() { App.restoreGame(gameId) }
)
}
}
onClosed: {
libraryView.restoreFocus()
}
}
Kirigami.OverlaySheet {
id: importSheet
title: i18n("Import Games")
implicitWidth: {
if (root.isMobile) return applicationWindow().width
if (root.isNarrowScreen) return applicationWindow().width - Kirigami.Units.largeSpacing * 2
return Math.min(applicationWindow().width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 32)
}
implicitHeight: {
if (root.isMobile) return applicationWindow().height
return Math.min(applicationWindow().height - Kirigami.Units.gridUnit * 2, importContent.implicitHeight + Kirigami.Units.gridUnit * 2)
}
onOpened: steamImportButton.forceActiveFocus()
onClosed: libraryView.restoreFocus()
function isDescendant(item, ancestor) {
let p = item
while (p) {
if (p === 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 (importSheet.isDescendant(next, importContent)) {
next.forceActiveFocus()
importScroll.ensureItemVisible(next)
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
}
}
Connections {
target: GamepadManager
function onNavigateUp() {
if (!importSheet.opened || importScroll.activeFocus) return
importSheet.focusNextInChain(false)
}
function onNavigateDown() {
if (!importSheet.opened || importScroll.activeFocus) return
importSheet.focusNextInChain(true)
}
function onSelectPressed() {
if (!importSheet.opened || importScroll.activeFocus) return
importSheet.activateFocused()
}
}
contentItem: QQC2.ScrollView {
id: importScroll
clip: true
leftPadding: Kirigami.Units.largeSpacing
rightPadding: Kirigami.Units.largeSpacing
topPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
function ensureItemVisible(item) {
if (!item || !importScroll.contentItem || !importScroll.contentItem.contentItem) return
let flick = importScroll.contentItem
let content = flick.contentItem
let p = item.mapToItem(content, 0, 0)
let itemTop = p.y
let itemBottom = p.y + item.height
let top = flick.contentY
let bottom = flick.contentY + flick.height
if (itemTop < top) {
flick.contentY = Math.max(0, itemTop)
} else if (itemBottom > bottom) {
flick.contentY = Math.max(0, itemBottom - flick.height)
}
}
function scrollBy(delta) {
if (!importScroll.contentItem) return
let maxY = Math.max(0, importScroll.contentItem.contentHeight - importScroll.contentItem.height)
importScroll.contentItem.contentY = Math.max(0, Math.min(maxY, importScroll.contentItem.contentY + delta))
}
Connections {
target: GamepadManager
function onNavigateUp() { if (importScroll.activeFocus) importScroll.scrollBy(-Kirigami.Units.gridUnit * 2) }
function onNavigateDown() { if (importScroll.activeFocus) importScroll.scrollBy(Kirigami.Units.gridUnit * 2) }
}
ColumnLayout {
id: importContent
width: importScroll.availableWidth
spacing: Kirigami.Units.mediumSpacing
Kirigami.InlineMessage {
Layout.fillWidth: true
type: Kirigami.MessageType.Information
text: App.importStatus
visible: App.importing
}
FormCard.FormCard {
Layout.fillWidth: true
FormCard.FormButtonDelegate {
id: steamImportButton
text: i18n("Steam")
description: i18n("Import from Steam library")
icon.name: ""
leading: IconWithResourceFallback {
primary: "com.valvesoftware.Steam"
secondary: "steam"
resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg")
}
enabled: !App.importing
onClicked: App.importFromSteam()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Lutris")
description: i18n("Import from Lutris")
icon.name: ""
leading: IconWithResourceFallback {
primary: "lutris"
secondary: "applications-games"
}
enabled: !App.importing
onClicked: App.importFromLutris()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Heroic")
description: i18n("Epic, GOG, Amazon games")
icon.name: ""
leading: IconWithResourceFallback {
primary: "com.heroicgameslauncher.hgl"
secondary: "applications-games"
}
enabled: !App.importing
onClicked: App.importFromHeroic()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Desktop Entries")
description: i18n("Games from system .desktop files")
icon.name: ""
leading: IconWithResourceFallback {
primary: "user-desktop"
secondary: "computer"
}
enabled: !App.importing
onClicked: App.importFromDesktop()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Bottles")
description: i18n("Wine applications from Bottles")
icon.name: ""
leading: IconWithResourceFallback {
primary: "com.usebottles.bottles"
secondary: "application-x-executable"
}
enabled: !App.importing
onClicked: App.importFromBottles()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Flatpak Games")
description: i18n("Games installed via Flatpak")
icon.name: ""
leading: IconWithResourceFallback {
primary: "flatpak-discover"
secondary: "applications-games"
}
enabled: !App.importing
onClicked: App.importFromFlatpak()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("itch.io")
description: i18n("Games from itch.io app")
icon.name: ""
leading: IconWithResourceFallback {
primary: "io.itch.itch"
secondary: "itch"
resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg")
}
enabled: !App.importing
onClicked: App.importFromItch()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Legendary")
description: i18n("Epic Games via Legendary CLI")
icon.name: ""
leading: IconWithResourceFallback {
primary: "legendary"
secondary: "applications-games"
}
enabled: !App.importing
onClicked: App.importFromLegendary()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("RetroArch")
description: i18n("Games from RetroArch playlists")
icon.name: ""
leading: IconWithResourceFallback {
primary: "org.libretro.RetroArch"
secondary: "retroarch"
resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg")
}
enabled: !App.importing
onClicked: App.importFromRetroArch()
}
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Kirigami.Units.mediumSpacing
QQC2.Button {
text: i18n("Import All")
icon.name: "document-import"
enabled: !App.importing
onClicked: App.importAllGames()
}
QQC2.Button {
text: i18n("Close")
onClicked: importSheet.close()
}
}
}
}
}
Kirigami.OverlaySheet {
id: settingsSheet
title: i18n("Settings")
implicitWidth: {
if (root.isMobile) return applicationWindow().width
if (root.isNarrowScreen) return applicationWindow().width - Kirigami.Units.largeSpacing * 2
return Math.min(applicationWindow().width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 30)
}
implicitHeight: {
if (root.isMobile) return applicationWindow().height
return Math.min(applicationWindow().height - Kirigami.Units.gridUnit * 2, settingsContent.implicitHeight + Kirigami.Units.gridUnit * 2)
}
onOpened: settingsContent.focusFirstControl()
onClosed: libraryView.restoreFocus()
function isDescendant(item, ancestor) {
let p = item
while (p) {
if (p === 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 (settingsSheet.isDescendant(next, settingsContent)) {
next.forceActiveFocus()
settingsScroll.ensureItemVisible(next)
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
}
}
Connections {
target: GamepadManager
function onNavigateUp() {
if (!settingsSheet.opened || settingsScroll.activeFocus) return
settingsSheet.focusNextInChain(false)
}
function onNavigateDown() {
if (!settingsSheet.opened || settingsScroll.activeFocus) return
settingsSheet.focusNextInChain(true)
}
function onSelectPressed() {
if (!settingsSheet.opened || settingsScroll.activeFocus) return
settingsSheet.activateFocused()
}
}
contentItem: QQC2.ScrollView {
id: settingsScroll
clip: true
leftPadding: Kirigami.Units.largeSpacing
rightPadding: Kirigami.Units.largeSpacing
topPadding: Kirigami.Units.largeSpacing
bottomPadding: Kirigami.Units.largeSpacing
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
function ensureItemVisible(item) {
if (!item || !settingsScroll.contentItem || !settingsScroll.contentItem.contentItem) return
let flick = settingsScroll.contentItem
let content = flick.contentItem
let p = item.mapToItem(content, 0, 0)
let itemTop = p.y
let itemBottom = p.y + item.height
let top = flick.contentY
let bottom = flick.contentY + flick.height
if (itemTop < top) {
flick.contentY = Math.max(0, itemTop)
} else if (itemBottom > bottom) {
flick.contentY = Math.max(0, itemBottom - flick.height)
}
}
function scrollBy(delta) {
if (!settingsScroll.contentItem) return
let maxY = Math.max(0, settingsScroll.contentItem.contentHeight - settingsScroll.contentItem.height)
settingsScroll.contentItem.contentY = Math.max(0, Math.min(maxY, settingsScroll.contentItem.contentY + delta))
}
Connections {
target: GamepadManager
function onNavigateUp() { if (settingsScroll.activeFocus) settingsScroll.scrollBy(-Kirigami.Units.gridUnit * 2) }
function onNavigateDown() { if (settingsScroll.activeFocus) settingsScroll.scrollBy(Kirigami.Units.gridUnit * 2) }
}
SettingsPage {
id: settingsContent
width: settingsScroll.availableWidth
}
}
}
GameEditDialog {
id: gameEditDialog
parent: root.overlay
onClosed: libraryView.restoreFocus()
onGameCreated: function(game) {
showPassiveNotification(i18n("Added %1", game.name))
}
onGameUpdated: function(game) {
showPassiveNotification(i18n("Updated %1", game.name))
}
}
Connections {
target: App
function onImportCompleted(count) {
showPassiveNotification(i18np("Imported %1 game", "Imported %1 games", count))
}
function onImportError(error) {
showPassiveNotification(i18n("Import error: %1", error), "long")
}
}
Connections {
target: App.launcher
function onGameStarted(game) {
showPassiveNotification(i18n("Launching %1...", game.name))
}
function onGameError(game, error) {
showPassiveNotification(i18n("Error launching %1: %2", game.name, error), "long")
}
}
Component.onCompleted: {
if (App.gameModel.count === 0) {
importSheet.open()
}
}
}