a-la-karte/src/qml/ConsoleCategoryRail.qml
Marco Allegretti 383857669b polish: add Left/Right key navigation to category rail tab delegates
When a tab delegate has focus, Left/Right now navigates to the adjacent tab and selects it via _applyIndex, mirroring the gamepad LB/RB behavior.
2026-03-24 11:36:07 +01:00

325 lines
14 KiB
QML

// 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.alakarte
import "components"
Item {
id: root
property string currentSource: "all"
signal sourceSelected(string source)
signal settingsRequested()
signal importRequested()
signal focusDownRequested()
function focusCurrent() {
if (tabList.currentItem) {
tabList.currentItem.forceActiveFocus()
return
}
tabList.forceActiveFocus()
}
function selectNext() {
if (sourceModel.count <= 0) return
let i = (tabList.currentIndex + 1) % sourceModel.count
_applyIndex(i)
}
function selectPrevious() {
if (sourceModel.count <= 0) return
let i = tabList.currentIndex - 1
if (i < 0) i = sourceModel.count - 1
_applyIndex(i)
}
function _applyIndex(i) {
tabList.currentIndex = i
let item = sourceModel.get(i)
if (item) {
root.currentSource = item.sourceId
root.sourceSelected(item.sourceId)
tabList.positionViewAtIndex(i, ListView.Contain)
}
}
function iconInfoForPlatform(platformName) {
let p = (platformName || "").toLowerCase()
if (p.includes("steam")) return { source: "com.valvesoftware.Steam", fallback: "steam", resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg") }
if (p.includes("itch")) return { source: "io.itch.itch", fallback: "applications-games", resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg") }
if (p.includes("retroarch")) return { source: "org.libretro.RetroArch", fallback: "applications-games", resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg") }
if (p.includes("lutris")) return { source: "lutris", fallback: "applications-games", resourceFallback: "" }
if (p.includes("heroic")) return { source: "com.heroicgameslauncher.hgl", fallback: "applications-games", resourceFallback: "" }
if (p.includes("bottles")) return { source: "com.usebottles.bottles", fallback: "application-x-executable", resourceFallback: "" }
if (p.includes("flatpak")) return { source: "flatpak-discover", fallback: "applications-games", resourceFallback: "" }
if (p.includes("desktop")) return { source: "user-desktop", fallback: "computer", resourceFallback: "" }
if (p.includes("legendary")) return { source: "legendary", fallback: "applications-games", resourceFallback: "" }
return { source: "applications-games", fallback: "applications-games", resourceFallback: "" }
}
onCurrentSourceChanged: {
for (let i = 0; i < sourceModel.count; ++i) {
if (sourceModel.get(i).sourceId === currentSource) {
tabList.currentIndex = i
return
}
}
}
ListModel {
id: sourceModel
Component.onCompleted: refresh()
function refresh() {
clear()
let allCount = 0, favCount = 0, hiddenCount = 0
let sources = {}
for (let i = 0; i < App.gameModel.rowCount(); i++) {
let game = App.gameModel.gameAt(i)
if (!game) continue
if (game.hidden) { hiddenCount++; continue }
allCount++
if (game.favorite) favCount++
let p = game.platform
sources[p] = (sources[p] || 0) + 1
}
append({ name: i18n("All"), sourceId: "all", icon: "view-list-icons", fallback: "applications-games", resourceFallback: "", count: allCount })
append({ name: i18n("Favorites"), sourceId: "favorites", icon: "bookmark-new", fallback: "bookmark-new", resourceFallback: "", count: favCount })
append({ name: i18n("Hidden"), sourceId: "hidden", icon: "view-hidden", fallback: "view-hidden", resourceFallback: "", count: hiddenCount })
for (let platform in sources) {
let info = root.iconInfoForPlatform(platform)
append({ name: platform, sourceId: platform, icon: info.source, fallback: info.fallback, resourceFallback: info.resourceFallback, count: sources[platform] })
}
for (let j = 0; j < count; ++j) {
if (get(j).sourceId === root.currentSource) {
tabList.currentIndex = j
break
}
}
}
}
Connections {
target: App.gameModel
function onCountChanged() { sourceModel.refresh() }
}
Connections {
target: App
function onImportCompleted() { sourceModel.refresh() }
}
RowLayout {
anchors.fill: parent
spacing: 0
QQC2.ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
QQC2.ScrollBar.vertical.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: tabList
model: sourceModel
orientation: ListView.Horizontal
spacing: Kirigami.Units.smallSpacing
leftMargin: Kirigami.Units.largeSpacing
rightMargin: Kirigami.Units.largeSpacing
topMargin: Kirigami.Units.smallSpacing
bottomMargin: Kirigami.Units.smallSpacing
clip: true
delegate: QQC2.ItemDelegate {
id: tabDelegate
width: implicitWidth
height: tabList.height - tabList.topMargin - tabList.bottomMargin
readonly property bool isActive: model.sourceId === root.currentSource
readonly property bool isFocused: tabDelegate.activeFocus || tabDelegate.hovered
hoverEnabled: true
scale: tabDelegate.isFocused ? 1.02 : (tabDelegate.isActive ? 1.01 : 1.0)
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
background: Kirigami.ShadowedRectangle {
radius: Kirigami.Units.smallSpacing
color: tabDelegate.isActive ? Kirigami.Theme.highlightColor
: Kirigami.Theme.alternateBackgroundColor
opacity: tabDelegate.isActive ? 0.20 : (tabDelegate.isFocused ? 0.10 : 0.0)
border.width: tabDelegate.isFocused ? 2 : 0
border.color: Kirigami.Theme.highlightColor
shadow.size: tabDelegate.isFocused ? Kirigami.Units.mediumSpacing : 0
shadow.color: Qt.rgba(0, 0, 0, 0.28)
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 2
radius: 1
color: Kirigami.Theme.highlightColor
visible: tabDelegate.isActive
}
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
Behavior on shadow.size {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
Behavior on border.width {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
}
leftPadding: Kirigami.Units.mediumSpacing
rightPadding: Kirigami.Units.mediumSpacing
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
IconWithResourceFallback {
primary: model.icon
secondary: model.fallback
resourceFallback: model.resourceFallback ? Qt.resolvedUrl(model.resourceFallback) : ""
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
color: tabDelegate.isActive ? Kirigami.Theme.highlightColor : Kirigami.Theme.textColor
}
QQC2.Label {
text: model.name
color: tabDelegate.isActive ? Kirigami.Theme.highlightColor : Kirigami.Theme.textColor
font.bold: tabDelegate.isActive
elide: Text.ElideRight
maximumLineCount: 1
}
}
onClicked: {
root.currentSource = model.sourceId
root.sourceSelected(model.sourceId)
}
Keys.onDownPressed: root.focusDownRequested()
Keys.onLeftPressed: {
if (tabList.currentIndex > 0) {
root._applyIndex(tabList.currentIndex - 1)
Qt.callLater(function() { if (tabList.currentItem) tabList.currentItem.forceActiveFocus() })
}
}
Keys.onRightPressed: {
if (tabList.currentIndex < sourceModel.count - 1) {
root._applyIndex(tabList.currentIndex + 1)
Qt.callLater(function() { if (tabList.currentItem) tabList.currentItem.forceActiveFocus() })
}
}
}
}
}
Kirigami.Separator { Layout.fillHeight: true }
QQC2.ToolButton {
id: importBtn
icon.name: "document-import"
onClicked: root.importRequested()
Layout.fillHeight: true
hoverEnabled: true
scale: activeFocus ? 1.02 : (hovered ? 1.01 : 1.0)
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
contentItem: Kirigami.Icon {
source: "document-import"
isMask: true
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
width: implicitWidth
height: implicitHeight
anchors.centerIn: parent
color: "white"
}
background: Kirigami.ShadowedRectangle {
radius: Kirigami.Units.smallSpacing
color: Kirigami.Theme.alternateBackgroundColor
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.10 : 0.0)
border.width: parent.activeFocus ? 2 : 0
border.color: Kirigami.Theme.highlightColor
shadow.size: parent.activeFocus ? Kirigami.Units.mediumSpacing : 0
shadow.color: Qt.rgba(0, 0, 0, 0.28)
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } }
Behavior on shadow.size { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } }
Behavior on border.width { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } }
}
QQC2.ToolTip.text: i18n("Import Games")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
Keys.onDownPressed: root.focusDownRequested()
}
QQC2.ToolButton {
id: settingsBtn
icon.name: "configure"
onClicked: root.settingsRequested()
Layout.fillHeight: true
hoverEnabled: true
scale: activeFocus ? 1.02 : (hovered ? 1.01 : 1.0)
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
contentItem: Kirigami.Icon {
source: "configure"
isMask: true
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
width: implicitWidth
height: implicitHeight
anchors.centerIn: parent
color: "white"
}
background: Kirigami.ShadowedRectangle {
radius: Kirigami.Units.smallSpacing
color: Kirigami.Theme.alternateBackgroundColor
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.10 : 0.0)
border.width: parent.activeFocus ? 2 : 0
border.color: Kirigami.Theme.highlightColor
shadow.size: parent.activeFocus ? Kirigami.Units.mediumSpacing : 0
shadow.color: Qt.rgba(0, 0, 0, 0.28)
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } }
Behavior on shadow.size { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } }
Behavior on border.width { NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } }
}
QQC2.ToolTip.text: i18n("Settings")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
Keys.onDownPressed: root.focusDownRequested()
}
}
}