// 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 (aboutSheet.opened) { aboutSheet.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 && !aboutSheet.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() }) } } } 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() } aboutSheet.open() } } } } 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") closePolicy: QQC2.Popup.CloseOnEscape | QQC2.Popup.CloseOnPressOutside 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, Kirigami.Units.gridUnit * 42) } onOpened: settingsContent.focusFirstControl() onClosed: libraryView.restoreFocus() header: Kirigami.ShadowedRectangle { id: settingsHeader implicitWidth: settingsSheet.implicitWidth implicitHeight: settingsHeaderRow.implicitHeight + Kirigami.Units.largeSpacing * 2 radius: Kirigami.Units.mediumSpacing color: Kirigami.Theme.backgroundColor shadow { size: Kirigami.Units.smallSpacing color: Qt.rgba(0, 0, 0, 0.20) } RowLayout { id: settingsHeaderRow anchors.fill: parent anchors.margins: Kirigami.Units.largeSpacing spacing: Kirigami.Units.mediumSpacing Kirigami.Heading { text: settingsSheet.title level: 2 Layout.fillWidth: true elide: Text.ElideRight } QQC2.ToolButton { text: i18n("Close") icon.name: "dialog-close" display: QQC2.AbstractButton.IconOnly onClicked: settingsSheet.close() QQC2.ToolTip.visible: hovered QQC2.ToolTip.text: text } } } 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, settingsSheet)) { next.forceActiveFocus() if (settingsSheet.isDescendant(next, settingsContent)) { 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 } } } Kirigami.OverlaySheet { id: aboutSheet title: i18n("About") closePolicy: QQC2.Popup.CloseOnEscape | QQC2.Popup.CloseOnPressOutside 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, Kirigami.Units.gridUnit * 42) } onClosed: libraryView.restoreFocus() header: Kirigami.ShadowedRectangle { id: aboutHeader implicitWidth: aboutSheet.implicitWidth implicitHeight: aboutHeaderRow.implicitHeight + Kirigami.Units.largeSpacing * 2 radius: Kirigami.Units.mediumSpacing color: Kirigami.Theme.backgroundColor shadow { size: Kirigami.Units.smallSpacing color: Qt.rgba(0, 0, 0, 0.20) } RowLayout { id: aboutHeaderRow anchors.fill: parent anchors.margins: Kirigami.Units.largeSpacing spacing: Kirigami.Units.mediumSpacing Kirigami.Heading { text: aboutSheet.title level: 2 Layout.fillWidth: true elide: Text.ElideRight } QQC2.ToolButton { text: i18n("Close") icon.name: "dialog-close" display: QQC2.AbstractButton.IconOnly onClicked: aboutSheet.close() QQC2.ToolTip.visible: hovered QQC2.ToolTip.text: text } } } contentItem: Item { Loader { id: aboutPageLoader anchors.fill: parent active: aboutSheet.opened sourceComponent: 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": [], "credits": [], "translators": [], "licenses": [ { "name": i18n("GNU General Public License v3.0 or later"), "text": "This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.\n\nThe full license text is available at:\nhttps://www.gnu.org/licenses/gpl-3.0.txt", "spdx": "GPL-3.0-or-later" } ], "copyrightStatement": "", "desktopFileName": "org.kde.alakarte" }) } } } } 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() } } }