Integrate MauiKit4/MauiMan4 as submodules, rewrite UI to MAUIkit console shell

- Add MauiMan4 + MauiKit4 submodules pinned at v4.0.2
- Wire both into CMake via CMAKE_FIND_PACKAGE_REDIRECTS_DIR build-tree stub
- Create MauiMan4 header shim for build-tree include layout
- Undefine QT_NO_CAST/QT_NO_FOREACH/QT_NO_KEYWORDS for MauiKit4 targets
- Link alakarte against MauiKit4
- Rewrite Main.qml: Maui.ApplicationWindow, dark console shell, blurred
  game backdrop, category rail, search bar, bottom gamepad hint bar
- Rewrite GameCard.qml: cover-art card with scale+glow focus effects
- Rewrite LibraryView.qml: Maui.GridBrowser with gamepad D-pad navigation
- New ConsoleCategoryRail.qml: horizontal pill category nav with L1/R1
- New ConsoleGameDetail.qml: cinematic full-screen game detail overlay
  with cover art, action buttons, fade-in animation, gamepad hints
This commit is contained in:
Marco Allegretti 2026-03-23 10:12:20 +01:00
parent e592e7b093
commit a0b9ea1832
9 changed files with 1083 additions and 2631 deletions

View file

@ -107,6 +107,8 @@ find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
WindowSystem
)
find_package(Qt6 COMPONENTS Multimedia QUIET)
find_package(KF6Auth ${KF_MIN_VERSION} QUIET)
find_package(KF6StatusNotifierItem ${KF_MIN_VERSION} QUIET)
@ -115,6 +117,56 @@ find_package(KF6KirigamiAddons 1.0.0 REQUIRED)
qt_policy(SET QTP0001 NEW)
set(MAUI_MAJOR_VERSION 4)
set(MAUIMAN_VERSION 4.0.2)
set(MAUIKIT_VERSION 4.0.2)
set(CMAKE_FIND_PACKAGE_REDIRECTS_DIR "${CMAKE_BINARY_DIR}/package_redirects")
file(MAKE_DIRECTORY "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}")
set(BUILD_DEMO OFF CACHE BOOL "" FORCE)
set(BUNDLE_LUV_ICONS OFF CACHE BOOL "" FORCE)
add_subdirectory(third-party/mauiman EXCLUDE_FROM_ALL)
set(_mauiman_shim "${CMAKE_BINARY_DIR}/mauiman_include")
file(MAKE_DIRECTORY "${_mauiman_shim}/MauiMan4")
file(COPY "${CMAKE_SOURCE_DIR}/third-party/mauiman/lib/src/modules/"
DESTINATION "${_mauiman_shim}/MauiMan4/"
FILES_MATCHING PATTERN "*.h")
file(COPY "${CMAKE_SOURCE_DIR}/third-party/mauiman/lib/src/mauimanutils.h"
DESTINATION "${_mauiman_shim}/MauiMan4/")
target_include_directories(MauiMan4 PUBLIC
"$<BUILD_INTERFACE:${_mauiman_shim}>")
file(WRITE "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/MauiMan4Config.cmake" [=[
set(MauiMan4_FOUND TRUE)
if(NOT TARGET MauiMan4::MauiMan4)
add_library(MauiMan4::MauiMan4 ALIAS MauiMan4)
endif()
]=])
file(WRITE "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/MauiMan4ConfigVersion.cmake" [=[
set(PACKAGE_VERSION "4.0.2")
set(PACKAGE_VERSION_EXACT TRUE)
set(PACKAGE_VERSION_COMPATIBLE TRUE)
set(PACKAGE_VERSION_UNSUITABLE FALSE)
]=])
add_subdirectory(third-party/mauikit EXCLUDE_FROM_ALL)
foreach(_mauikit_target MauiKit4 MauiKit4plugin)
if(TARGET ${_mauikit_target})
target_compile_options(${_mauikit_target} PRIVATE
"-UQT_NO_CAST_FROM_ASCII"
"-UQT_NO_CAST_TO_ASCII"
"-UQT_NO_CAST_FROM_BYTEARRAY"
"-UQT_NO_URL_CAST_FROM_STRING"
"-UQT_NO_FOREACH"
"-UQT_NO_KEYWORDS")
endif()
endforeach()
add_subdirectory(src)
add_subdirectory(src/krunner)
add_subdirectory(icons)

View file

@ -78,6 +78,7 @@ target_link_libraries(alakarte PRIVATE
SDL3::SDL3
KF6::Kirigami
KF6::I18n
MauiKit4
KF6::CoreAddons
KF6::ConfigCore
KF6::ConfigGui
@ -104,8 +105,10 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
QML_FILES
qml/Main.qml
qml/LibraryView.qml
qml/GameCard.qml
qml/ConsoleGameDetail.qml
qml/ConsoleCategoryRail.qml
qml/CouchSidebar.qml
qml/GameCard.qml
qml/GameDetailsSheet.qml
qml/DiagnosticsSheet.qml
qml/SettingsPage.qml

View file

@ -0,0 +1,161 @@
// 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.mauikit.controls as Maui
import org.kde.alakarte
Item {
id: root
property string currentCategory: "all"
signal categorySelected(string categoryId)
function selectNext() {
if (rail.currentIndex < categoryModel.count - 1) {
rail.currentIndex++
categorySelected(categoryModel.get(rail.currentIndex).categoryId)
}
}
function selectPrevious() {
if (rail.currentIndex > 0) {
rail.currentIndex--
categorySelected(categoryModel.get(rail.currentIndex).categoryId)
}
}
function _platformIcon(name) {
let p = (name || "").toLowerCase()
if (p.includes("steam")) return ":/icons/brand/steam-symbolic.svg"
if (p.includes("itch")) return ":/icons/brand/itchdotio-symbolic.svg"
if (p.includes("retroarch")) return ":/icons/brand/retroarch-symbolic.svg"
if (p.includes("lutris")) return "applications-games"
if (p.includes("heroic")) return "applications-games"
if (p.includes("bottles")) return "application-x-executable"
if (p.includes("flatpak")) return "applications-games"
if (p.includes("desktop")) return "computer"
return "applications-games"
}
GameSortFilterModel {
id: allGames
sourceModel: App.gameModel
showHidden: false
}
ListModel {
id: categoryModel
Component.onCompleted: rebuild()
function rebuild() {
clear()
append({ label: i18n("All"), categoryId: "all", iconSource: "view-list-icons" })
append({ label: i18n("Favorites"), categoryId: "favorites", iconSource: "starred-symbolic" })
let seen = {}
for (let i = 0; i < allGames.count; ++i) {
let g = allGames.get(i)
if (!g) continue
let p = g.platform || ""
if (p && !seen[p]) {
seen[p] = true
append({ label: p, categoryId: p, iconSource: root._platformIcon(p) })
}
}
for (let j = 0; j < count; ++j) {
if (get(j).categoryId === root.currentCategory) {
rail.currentIndex = j
break
}
}
}
}
Connections {
target: App.gameModel
function onCountChanged() { categoryModel.rebuild() }
}
onCurrentCategoryChanged: {
for (let j = 0; j < categoryModel.count; ++j) {
if (categoryModel.get(j).categoryId === currentCategory) {
rail.currentIndex = j
return
}
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.55)
ListView {
id: rail
anchors {
fill: parent
leftMargin: 24
rightMargin: 24
}
orientation: ListView.Horizontal
spacing: 8
clip: true
model: categoryModel
keyNavigationEnabled: true
highlightMoveDuration: 200
delegate: QQC2.ItemDelegate {
id: pill
width: implicitContentWidth + 32
height: rail.height
topPadding: 0
bottomPadding: 0
leftPadding: 16
rightPadding: 16
readonly property bool isCurrent: ListView.isCurrentItem
background: Rectangle {
radius: height / 2
color: pill.isCurrent
? Maui.Theme.highlightColor
: (pill.hovered ? Qt.rgba(1, 1, 1, 0.12) : Qt.rgba(1, 1, 1, 0.06))
border.color: pill.isCurrent ? Maui.Theme.highlightColor : Qt.rgba(1, 1, 1, 0.18)
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
}
contentItem: RowLayout {
spacing: 6
Maui.Icon {
source: model.iconSource
Layout.preferredWidth: 16
Layout.preferredHeight: 16
color: "white"
opacity: pill.isCurrent ? 1.0 : 0.75
}
QQC2.Label {
text: model.label
color: "white"
opacity: pill.isCurrent ? 1.0 : 0.75
font.pixelSize: 14
font.weight: pill.isCurrent ? Font.DemiBold : Font.Normal
}
}
onClicked: {
rail.currentIndex = index
root.categorySelected(model.categoryId)
}
}
}
}
}

View file

@ -0,0 +1,379 @@
// 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.Effects
import org.mauikit.controls as Maui
import org.kde.alakarte
Item {
id: detailRoot
property var game: null
signal close()
signal launch()
signal editRequested()
signal removeRequested()
visible: game !== null
focus: visible
Keys.onEscapePressed: detailRoot.close()
Connections {
target: GamepadManager
function onBackPressed() {
if (detailRoot.visible) detailRoot.close()
}
function onSelectPressed() {
if (detailRoot.visible) detailRoot.launch()
}
function onDetailsPressed() {
if (detailRoot.visible) detailRoot.editRequested()
}
}
Rectangle {
anchors.fill: parent
color: "#CC000000"
}
Image {
id: artBackdrop
anchors.fill: parent
source: detailRoot.game ? detailRoot.game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: false
}
MultiEffect {
source: artBackdrop
anchors.fill: artBackdrop
blurEnabled: true
blur: 1.0
blurMax: 80
brightness: -0.2
opacity: 0.45
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.82) }
GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.46) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.10) }
}
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.0) }
GradientStop { position: 0.72; color: Qt.rgba(0, 0, 0, 0.0) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.75) }
}
}
RowLayout {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
top: parent.top
leftMargin: 64
rightMargin: 48
bottomMargin: 64
topMargin: 48
}
spacing: 48
Item {
Layout.preferredWidth: Math.round(parent.height * 0.42)
Layout.fillHeight: true
Rectangle {
id: coverArt
anchors.centerIn: parent
width: Math.round(parent.width * 0.85)
height: Math.round(width * 1.45)
radius: 12
color: "#1a1a2a"
clip: true
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Qt.rgba(0, 0, 0, 0.7)
shadowBlur: 0.7
shadowHorizontalOffset: 0
shadowVerticalOffset: 12
}
Image {
anchors.fill: parent
source: detailRoot.game ? detailRoot.game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
Item { Layout.fillHeight: true; Layout.preferredHeight: 1 }
QQC2.Label {
Layout.fillWidth: true
text: detailRoot.game ? detailRoot.game.name : ""
color: "white"
font.pixelSize: 52
font.weight: Font.Bold
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
lineHeight: 1.1
style: Text.Raised
styleColor: Qt.rgba(0, 0, 0, 0.5)
}
Item { Layout.preferredHeight: 12 }
RowLayout {
spacing: 12
visible: detailRoot.game && detailRoot.game.platform
Rectangle {
radius: 4
color: Qt.rgba(1, 1, 1, 0.15)
border.color: Qt.rgba(1, 1, 1, 0.3)
border.width: 1
implicitWidth: platformLabel.implicitWidth + 20
implicitHeight: platformLabel.implicitHeight + 8
QQC2.Label {
id: platformLabel
anchors.centerIn: parent
text: detailRoot.game ? (detailRoot.game.platform || "") : ""
color: "white"
font.pixelSize: 13
font.weight: Font.Medium
}
}
Rectangle {
radius: 4
color: "#4caf50"
implicitWidth: runningLabel.implicitWidth + 16
implicitHeight: runningLabel.implicitHeight + 6
visible: detailRoot.game && detailRoot.game.running
QQC2.Label {
id: runningLabel
anchors.centerIn: parent
text: i18n("Running")
color: "white"
font.pixelSize: 12
font.weight: Font.Medium
}
}
}
Item { Layout.preferredHeight: 48 }
RowLayout {
spacing: 16
QQC2.Button {
id: playBtn
text: i18n("Play")
icon.name: "media-playback-start"
focus: true
KeyNavigation.right: favoriteBtn
contentItem: RowLayout {
spacing: 8
Maui.Icon {
source: "media-playback-start"
width: 22; height: 22
color: "white"
}
QQC2.Label {
text: i18n("Play")
color: "white"
font.pixelSize: 17
font.weight: Font.DemiBold
}
}
background: Rectangle {
radius: 8
color: playBtn.activeFocus
? Maui.Theme.highlightColor
: (playBtn.hovered ? Qt.rgba(1,1,1,0.22) : Qt.rgba(1,1,1,0.14))
border.color: playBtn.activeFocus ? Maui.Theme.highlightColor : Qt.rgba(1,1,1,0.28)
border.width: playBtn.activeFocus ? 0 : 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 160
implicitHeight: 52
onClicked: detailRoot.launch()
Keys.onReturnPressed: detailRoot.launch()
Keys.onEnterPressed: detailRoot.launch()
}
QQC2.Button {
id: favoriteBtn
icon.name: detailRoot.game && detailRoot.game.favorite ? "starred-symbolic" : "non-starred-symbolic"
KeyNavigation.left: playBtn
KeyNavigation.right: editBtn
contentItem: Maui.Icon {
source: detailRoot.game && detailRoot.game.favorite ? "starred-symbolic" : "non-starred-symbolic"
width: 22; height: 22
color: detailRoot.game && detailRoot.game.favorite ? "#f5c518" : "white"
}
background: Rectangle {
radius: 8
color: favoriteBtn.activeFocus
? Qt.rgba(1,1,1,0.22)
: (favoriteBtn.hovered ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.10))
border.color: favoriteBtn.activeFocus ? "white" : Qt.rgba(1,1,1,0.25)
border.width: 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 52
implicitHeight: 52
QQC2.ToolTip.visible: hovered || activeFocus
QQC2.ToolTip.text: detailRoot.game && detailRoot.game.favorite
? i18n("Remove from Favorites") : i18n("Add to Favorites")
QQC2.ToolTip.delay: 800
onClicked: if (detailRoot.game) detailRoot.game.favorite = !detailRoot.game.favorite
}
QQC2.Button {
id: editBtn
icon.name: "document-edit"
KeyNavigation.left: favoriteBtn
KeyNavigation.right: closeBtn
contentItem: Maui.Icon {
source: "document-edit"
width: 20; height: 20
color: "white"
}
background: Rectangle {
radius: 8
color: editBtn.activeFocus
? Qt.rgba(1,1,1,0.22)
: (editBtn.hovered ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.10))
border.color: editBtn.activeFocus ? "white" : Qt.rgba(1,1,1,0.25)
border.width: 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 52
implicitHeight: 52
QQC2.ToolTip.visible: hovered || activeFocus
QQC2.ToolTip.text: i18n("Edit")
QQC2.ToolTip.delay: 800
onClicked: detailRoot.editRequested()
}
QQC2.Button {
id: closeBtn
icon.name: "window-close"
KeyNavigation.left: editBtn
contentItem: Maui.Icon {
source: "window-close"
width: 20; height: 20
color: "white"
}
background: Rectangle {
radius: 8
color: closeBtn.activeFocus
? Qt.rgba(1,1,1,0.22)
: (closeBtn.hovered ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.10))
border.color: closeBtn.activeFocus ? "white" : Qt.rgba(1,1,1,0.25)
border.width: 1
Behavior on color { ColorAnimation { duration: 100 } }
}
implicitWidth: 52
implicitHeight: 52
QQC2.ToolTip.visible: hovered || activeFocus
QQC2.ToolTip.text: i18n("Back")
QQC2.ToolTip.delay: 800
onClicked: detailRoot.close()
}
}
Item { Layout.preferredHeight: 32 }
RowLayout {
spacing: 28
Repeater {
model: [
{ icon: ":/icons/gamepad/generic/south.svg", label: i18n("Play") },
{ icon: ":/icons/gamepad/generic/east.svg", label: i18n("Back") },
{ icon: ":/icons/gamepad/generic/north.svg", label: i18n("Edit") }
]
RowLayout {
spacing: 6
visible: GamepadManager.connected
Image {
source: modelData.icon
width: 22; height: 22
fillMode: Image.PreserveAspectFit
smooth: true
}
QQC2.Label {
text: modelData.label
color: Qt.rgba(1, 1, 1, 0.65)
font.pixelSize: 13
}
}
}
}
Item { Layout.fillHeight: true; Layout.preferredHeight: 1 }
}
}
NumberAnimation on opacity {
id: fadeIn
running: detailRoot.visible
from: 0; to: 1
duration: 220
easing.type: Easing.OutCubic
}
onVisibleChanged: {
if (visible) {
Qt.callLater(function() { playBtn.forceActiveFocus() })
}
}
}

View file

@ -1,314 +1,168 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Effects
import org.kde.kirigami as Kirigami
import org.mauikit.controls as Maui
import org.kde.alakarte
FocusScope {
Item {
id: gameCard
property var game
property bool showPlayButton: true
property bool focused: activeFocus
signal clicked()
signal playClicked()
readonly property bool isTouchDevice: {
let w = applicationWindow()
if (w && w.isTouchDevice !== undefined) return w.isTouchDevice
return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
}
readonly property real adaptiveHoverScale: root.isCouchMode ? 1.05 : 1.015
readonly property real adaptiveFocusScale: root.isCouchMode ? 1.12 : 1.03
readonly property int adaptiveFocusRingWidth: root.isCouchMode ? 3 : 1
readonly property bool isCurrent: GridView.isCurrentItem
readonly property bool useAnimatedCover: App.config.animatedCovers
&& game
&& game.coverUrl
&& game && game.coverUrl
&& game.coverUrl.toString().toLowerCase().endsWith(".gif")
readonly property int coverStatus: useAnimatedCover ? animatedCover.status : staticCover.status
signal clicked()
signal doubleClicked()
signal playClicked()
width: GridView.view ? GridView.view.cellWidth : 200
height: GridView.view ? GridView.view.cellHeight : 300
Kirigami.ShadowedRectangle {
id: cardBackground
anchors.fill: parent
radius: Kirigami.Units.mediumSpacing
Item {
id: cardContainer
anchors.centerIn: parent
width: parent.width - 16
height: parent.height - 16
color: Kirigami.Theme.backgroundColor
shadow {
size: gameCard.focused ? (root.isCouchMode ? Kirigami.Units.gridUnit * 1.5 : Kirigami.Units.mediumSpacing) : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing)
color: gameCard.focused ? (root.isCouchMode ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) : Qt.rgba(0, 0, 0, 0.34)) : (hoverHandler.hovered ? Qt.rgba(0, 0, 0, 0.24) : Qt.rgba(0, 0, 0, 0.16))
}
Behavior on shadow.color {
ColorAnimation { duration: Kirigami.Units.shortDuration }
}
border.width: gameCard.focused ? gameCard.adaptiveFocusRingWidth : 0
border.color: Kirigami.Theme.highlightColor
Behavior on border.width {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
Behavior on shadow.size {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
y: gameCard.focused ? -Kirigami.Units.smallSpacing : (hoverHandler.hovered ? -Kirigami.Units.smallSpacing * 0.5 : 0)
Behavior on y {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
scale: gameCard.focused ? gameCard.adaptiveFocusScale : (hoverHandler.hovered ? gameCard.adaptiveHoverScale : 1.0)
scale: gameCard.isCurrent ? 1.08 : (hoverHandler.hovered ? 1.03 : 1.0)
Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
layer.enabled: gameCard.isCurrent
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Qt.rgba(
Maui.Theme.highlightColor.r,
Maui.Theme.highlightColor.g,
Maui.Theme.highlightColor.b, 0.55)
shadowBlur: 0.8
shadowHorizontalOffset: 0
shadowVerticalOffset: 6
}
Rectangle {
id: coverFrame
anchors.fill: parent
radius: Kirigami.Units.mediumSpacing
color: "transparent"
border.width: 2
border.color: Kirigami.Theme.highlightColor
opacity: 0.0
visible: gameCard.focused
radius: 8
color: "#1a1a2a"
clip: true
SequentialAnimation on opacity {
running: gameCard.focused
loops: Animation.Infinite
NumberAnimation { from: 0.10; to: 0.28; duration: 900; easing.type: Easing.InOutQuad }
NumberAnimation { from: 0.28; to: 0.12; duration: 900; easing.type: Easing.InOutQuad }
Image {
id: staticCover
anchors.fill: parent
source: game ? game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: !gameCard.useAnimatedCover
smooth: true
mipmap: true
}
}
// Cover image
Image {
id: staticCover
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
source: game ? game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: !gameCard.useAnimatedCover
smooth: true
mipmap: App.config.highQualityImages
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: ShaderEffectSource {
sourceItem: Rectangle {
width: staticCover.width
height: staticCover.height
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
}
}
AnimatedImage {
id: animatedCover
anchors.fill: parent
source: game ? game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
playing: gameCard.isCurrent
visible: gameCard.useAnimatedCover
smooth: true
}
}
AnimatedImage {
id: animatedCover
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
source: game ? game.coverUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
playing: true
visible: gameCard.useAnimatedCover
smooth: true
mipmap: App.config.highQualityImages
sourceSize.width: Math.round(width * (App.config.highQualityImages ? 2 : 1))
sourceSize.height: Math.round(height * (App.config.highQualityImages ? 2 : 1))
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: ShaderEffectSource {
sourceItem: Rectangle {
width: animatedCover.width
height: animatedCover.height
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
}
}
}
}
// Placeholder when no cover
Rectangle {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
color: Kirigami.Theme.alternateBackgroundColor
visible: gameCard.coverStatus !== Image.Ready
Kirigami.Icon {
Maui.Icon {
anchors.centerIn: parent
source: "applications-games"
width: parent.width * 0.4
width: parent.width * 0.38
height: width
color: Kirigami.Theme.disabledTextColor
color: "#44ffffff"
visible: gameCard.coverStatus !== Image.Ready
}
}
// Gradient overlay for text
Rectangle {
opacity: (!root.isCouchMode || gameCard.focused || hoverHandler.hovered) ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } }
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Kirigami.Units.smallSpacing
height: parent.height * 0.4
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.5; color: Qt.rgba(0, 0, 0, 0.5) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.85) }
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: parent.height * 0.42
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.62) }
GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.92) }
}
}
}
// Game title
ColumnLayout {
opacity: (!root.isCouchMode || gameCard.focused || hoverHandler.hovered) ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } }
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Kirigami.Units.mediumSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
margins: 10
}
text: game ? game.name : ""
font.bold: true
font.pointSize: Kirigami.Theme.defaultFont.pointSize
color: "white"
font.pixelSize: 13
font.weight: Font.Medium
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: 2
opacity: gameCard.isCurrent || hoverHandler.hovered ? 1.0 : 0.85
}
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
visible: App.config.showPlatformBadges
Rectangle {
implicitWidth: platformLabel.implicitWidth + Kirigami.Units.largeSpacing
implicitHeight: platformLabel.implicitHeight + Kirigami.Units.smallSpacing
Layout.preferredWidth: implicitWidth
Layout.preferredHeight: implicitHeight
radius: Kirigami.Units.smallSpacing
color: getPlatformColor(game ? game.platform : "")
QQC2.Label {
id: platformLabel
anchors.centerIn: parent
text: getPlatformDisplayName(game ? game.platform : "")
font.pointSize: Kirigami.Theme.smallFont.pointSize
color: "white"
}
}
Item { Layout.fillWidth: true }
}
}
// Favorite indicator
Kirigami.Icon {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Kirigami.Units.mediumSpacing
width: Kirigami.Units.iconSizes.medium
height: width
source: "bookmark-new"
color: Kirigami.Theme.positiveTextColor
visible: game && game.favorite
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Qt.rgba(0, 0, 0, 0.5)
shadowBlur: 0.5
}
}
// Running indicator
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: Kirigami.Units.mediumSpacing
width: Kirigami.Units.iconSizes.small
height: width
radius: width / 2
color: Kirigami.Theme.positiveTextColor
visible: game && game.running
SequentialAnimation on opacity {
running: game && game.running
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 800 }
NumberAnimation { to: 1.0; duration: 800 }
}
}
// Play button overlay
Rectangle {
id: playOverlay
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
radius: Kirigami.Units.mediumSpacing - Kirigami.Units.smallSpacing
color: Qt.rgba(0, 0, 0, 0.6)
opacity: (hoverHandler.hovered || gameCard.focused) && showPlayButton ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.huge
height: width
source: "media-playback-start"
color: "white"
}
}
HoverHandler {
id: hoverHandler
}
TapHandler {
onTapped: {
if (App.config.coverLaunchesGame) {
gameCard.playClicked()
} else {
gameCard.clicked()
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: 8
width: 9
height: 9
radius: 5
color: "#4caf50"
visible: game && game.running
SequentialAnimation on opacity {
running: game && game.running
loops: Animation.Infinite
NumberAnimation { to: 0.25; duration: 700 }
NumberAnimation { to: 1.0; duration: 700 }
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onDoubleTapped: {
gameCard.doubleClicked()
gameCard.playClicked()
Maui.Icon {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
width: 18
height: 18
source: "starred-symbolic"
color: "#f5c518"
visible: game && game.favorite
}
}
// Context menu
Rectangle {
anchors.fill: coverFrame
radius: 8
color: "transparent"
border.color: Maui.Theme.highlightColor
border.width: gameCard.isCurrent ? 3 : 0
opacity: gameCard.isCurrent ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 120 } }
Behavior on border.width { NumberAnimation { duration: 120 } }
}
HoverHandler { id: hoverHandler }
TapHandler {
onTapped: gameCard.clicked()
onDoubleTapped: gameCard.playClicked()
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: contextMenu.popup()
@ -322,48 +176,23 @@ FocusScope {
icon.name: "media-playback-start"
onTriggered: gameCard.playClicked()
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: game && game.favorite ? i18n("Remove from Favorites") : i18n("Add to Favorites")
icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new"
icon.name: game && game.favorite ? "bookmark-remove" : "starred-symbolic"
onTriggered: if (game) game.favorite = !game.favorite
}
QQC2.MenuItem {
text: game && game.hidden ? i18n("Show in Library") : i18n("Hide from Library")
icon.name: game && game.hidden ? "view-visible" : "view-hidden"
onTriggered: if (game) game.hidden = !game.hidden
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: i18n("View Details")
icon.name: "documentation"
icon.name: "documentinfo"
onTriggered: gameCard.clicked()
}
}
}
function getPlatformColor(platform) {
if (!platform) return Kirigami.Theme.highlightColor
if (platform.includes("Steam")) return "#1b2838"
if (platform.includes("Lutris")) return "#ff9800"
if (platform.includes("Epic")) return "#0078f2"
if (platform.includes("GOG")) return "#86328a"
if (platform.includes("Amazon")) return "#ff9900"
return Kirigami.Theme.highlightColor
}
function getPlatformDisplayName(platform) {
if (!platform) return ""
if (platform.includes("Steam")) return "Steam"
if (platform.includes("Lutris")) return "Lutris"
if (platform.includes("Epic")) return "Epic"
if (platform.includes("GOG")) return "GOG"
if (platform.includes("Amazon")) return "Amazon"
return platform
}
}

View file

@ -1,11 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Effects
import org.kde.kirigami as Kirigami
import org.mauikit.controls as Maui
import org.kde.alakarte
import "components"
@ -13,321 +12,125 @@ FocusScope {
id: libraryRoot
property string filterSource: "all"
property bool searchActive: false
property int focusedIndex: -1
property int adaptiveCardSize: App.config.gridSize
property bool isTouchDevice: false
property string filterText: ""
signal gameSelected(var game)
signal gameFocused(var game)
signal gameLaunched(var game)
readonly property int gameCount: proxyModel.count
property url focusedCoverUrl: ""
readonly property bool anyMenuOpen: searchHeader.anyMenuOpen
function closeCurrentMenu() {
searchHeader.closeCurrentMenu()
}
function focusSearch() {
searchField.forceActiveFocus()
}
function clearSearch() {
searchField.text = ""
proxyModel.filterText = ""
}
function restoreFocus() {
let w = applicationWindow()
if (w && w.hasOwnProperty("pendingSidebarOpen") && w.pendingSidebarOpen) {
w.pendingSidebarOpen = false
if (w.globalDrawer && typeof w.globalDrawer.open === "function") {
w.globalDrawer.open()
return
}
}
if (libraryRoot.searchActive) {
libraryRoot.focusSearch()
} else {
if (libraryRoot.focusedIndex >= 0 && libraryRoot.focusedIndex < proxyModel.count) {
gameGrid.currentIndex = libraryRoot.focusedIndex
}
gameGrid.forceActiveFocus()
}
grid.forceActiveFocus()
}
function launchFocusedGame() {
if (!gameGrid || !proxyModel) return
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) {
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex)
if (game) {
libraryRoot.gameLaunched(game)
}
function focusedGame() {
if (grid.currentIndex < 0) return null
return proxyModel.get(grid.currentIndex)
}
function openDetailsForFocusedGame() {
if (!gameGrid || !proxyModel) return
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) {
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex)
if (game) {
libraryRoot.gameSelected(game)
}
function selectFocused() {
let g = focusedGame()
if (g) libraryRoot.gameSelected(g)
}
onSearchActiveChanged: {
if (!libraryRoot.searchActive) {
libraryRoot.clearSearch()
Qt.callLater(function() {
gameGrid.forceActiveFocus()
})
}
function launchFocused() {
let g = focusedGame()
if (g) libraryRoot.gameLaunched(g)
}
Item {
GameSortFilterModel {
id: proxyModel
sourceModel: App.gameModel
showHidden: false
favoritesOnly: libraryRoot.filterSource === "favorites"
filterSource: {
if (libraryRoot.filterSource === "all") return ""
if (libraryRoot.filterSource === "favorites") return ""
return libraryRoot.filterSource
}
filterText: libraryRoot.filterText
}
Maui.GridBrowser {
id: grid
anchors.fill: parent
anchors.margins: 0
focus: true
Item {
anchors.fill: parent
visible: libraryRoot.gameCount > 0
model: proxyModel
itemSize: Math.max(160, Math.min(App.config.gridSize, 280))
itemHeight: Math.round(grid.itemSize * 1.5)
adaptContent: true
enableLassoSelection: false
Image {
id: backgroundCoverA
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: source.toString().length > 0
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.0
topPadding: 16
bottomPadding: 24
leftPadding: 24
rightPadding: 24
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic }
}
holder.visible: proxyModel.count === 0 && !App.importing
holder.emoji: libraryRoot.filterSource === "favorites" ? "starred-symbolic"
: "applications-games"
holder.title: libraryRoot.filterSource === "favorites"
? i18n("No favorites yet")
: i18n("Your library is empty")
holder.body: libraryRoot.filterSource === "favorites"
? i18n("Mark games as favorites to see them here")
: i18n("Import games to get started")
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.9
blurMax: 64
}
onCurrentIndexChanged: {
let g = proxyModel.get(currentIndex)
if (g) libraryRoot.gameFocused(g)
}
delegate: GameCard {
game: model.gameObject
onClicked: {
grid.currentIndex = index
libraryRoot.gameSelected(model.gameObject)
}
Image {
id: backgroundCoverB
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: source.toString().length > 0
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic }
}
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.9
blurMax: 64
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.55)
onPlayClicked: {
grid.currentIndex = index
libraryRoot.gameLaunched(model.gameObject)
}
}
ColumnLayout {
anchors.fill: parent
spacing: Kirigami.Units.smallSpacing
Keys.onReturnPressed: libraryRoot.selectFocused()
Keys.onEnterPressed: libraryRoot.selectFocused()
Keys.onSpacePressed: libraryRoot.selectFocused()
SearchHeader {
id: searchHeader
Layout.fillWidth: true
visible: libraryRoot.searchActive
// In Couch Mode, position it absolute over the grid instead of shifting the grid
// to mimic a console drop-down search bar
y: root.isCouchMode ? Kirigami.Units.gridUnit * 2 : 0
z: 100
searchField: searchField
onSearchChanged: function(text) {
proxyModel.filterText = text
}
onSortChanged: function(mode) {
proxyModel.sortMode = mode
}
Kirigami.SearchField {
id: searchField
Layout.fillWidth: true
placeholderText: i18n("Search games...")
onTextChanged: proxyModel.filterText = text
Keys.onEscapePressed: {
text = ""
let w = applicationWindow()
if (w && w.hasOwnProperty("searchActive")) {
w.searchActive = false
} else {
libraryRoot.searchActive = false
}
libraryRoot.restoreFocus()
}
Keys.onDownPressed: gameGrid.forceActiveFocus()
Connections {
target: GamepadManager
function onNavigateDown() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!searchField.activeFocus) return
gameGrid.forceActiveFocus()
}
}
}
QQC2.BusyIndicator {
anchors.centerIn: parent
running: App.importing
visible: App.importing
}
GameGridView {
id: gameGrid
Layout.fillWidth: true
Layout.fillHeight: true
cardSize: libraryRoot.adaptiveCardSize
onCurrentIndexChanged: {
if (gameGrid.activeFocus) {
libraryRoot.focusedIndex = currentIndex
}
let game = proxyModel.get(currentIndex)
let url = (game && game.coverUrl) ? game.coverUrl : ""
if (url === libraryRoot.focusedCoverUrl) return
if (backgroundCoverA.opacity > 0.1) {
backgroundCoverB.source = url
backgroundCoverB.opacity = 0.0
backgroundCoverA.opacity = 0.0
Qt.callLater(function() { backgroundCoverB.opacity = 0.22 })
} else {
backgroundCoverA.source = url
backgroundCoverA.opacity = 0.0
backgroundCoverB.opacity = 0.0
Qt.callLater(function() { backgroundCoverA.opacity = 0.22 })
}
libraryRoot.focusedCoverUrl = url
Connections {
target: GamepadManager
function onSelectPressed() {
if (!grid.activeFocus) return
libraryRoot.selectFocused()
}
model: GameSortFilterModel {
id: proxyModel
sourceModel: App.gameModel
showHidden: libraryRoot.filterSource === "hidden"
favoritesOnly: libraryRoot.filterSource === "favorites"
filterSource: {
if (libraryRoot.filterSource === "all") return ""
if (libraryRoot.filterSource === "favorites") return ""
if (libraryRoot.filterSource === "hidden") return ""
return libraryRoot.filterSource
}
}
delegate: Item {
width: gameGrid.cellWidth
height: gameGrid.cellHeight
function clicked() {
gameGrid.currentIndex = index
libraryRoot.focusedIndex = index
card.clicked()
}
function play() {
gameGrid.currentIndex = index
libraryRoot.focusedIndex = index
card.playClicked()
}
GameCard {
id: card
width: gameGrid.cardSize
height: Math.round(gameGrid.cardSize * 1.4)
anchors.centerIn: parent
game: model.gameObject
focused: gameGrid.currentIndex === index && gameGrid.activeFocus
onClicked: libraryRoot.gameSelected(model.gameObject)
onDoubleClicked: libraryRoot.gameLaunched(model.gameObject)
onPlayClicked: libraryRoot.gameLaunched(model.gameObject)
Keys.onReturnPressed: libraryRoot.gameSelected(model.gameObject)
Keys.onEnterPressed: libraryRoot.gameSelected(model.gameObject)
Keys.onSpacePressed: libraryRoot.gameLaunched(model.gameObject)
}
function onNavigateUp() {
if (!grid.activeFocus) return
if (grid.currentIndex <= 0) return
let cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
grid.currentIndex = Math.max(0, grid.currentIndex - cols)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (currentIndex < 0 && proxyModel.count > 0) {
currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(currentIndex)
if (game) {
libraryRoot.gameSelected(game)
}
event.accepted = true
} else if (event.key === Qt.Key_Space) {
if (currentIndex < 0 && proxyModel.count > 0) {
currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(currentIndex)
if (game) {
libraryRoot.gameLaunched(game)
}
event.accepted = true
}
function onNavigateDown() {
if (!grid.activeFocus) return
let cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
grid.currentIndex = Math.min(proxyModel.count - 1, grid.currentIndex + cols)
}
EmptyState {
anchors.centerIn: parent
visible: proxyModel.count === 0 && !App.importing
icon: proxyModel.filterText.length > 0 ? "edit-find" : (libraryRoot.filterSource === "favorites" ? "bookmark-new" : (libraryRoot.filterSource === "hidden" ? "view-hidden" : "applications-games"))
title: proxyModel.filterText.length > 0 ?
i18n("No games found") : (libraryRoot.filterSource === "favorites" ? i18n("No favorites yet") : (libraryRoot.filterSource === "hidden" ? i18n("No hidden games") : i18n("Your library is empty")))
description: proxyModel.filterText.length > 0 ?
i18n("Try adjusting your search") : (libraryRoot.filterSource === "favorites" ? i18n("Mark games as favorites to see them here") : (libraryRoot.filterSource === "hidden" ? i18n("Hidden games will appear here") : i18n("Import games to get started")))
actionText: (proxyModel.filterText.length > 0 || libraryRoot.filterSource === "favorites" || libraryRoot.filterSource === "hidden") ? "" : i18n("Import Games")
onActionTriggered: App.importAllGames()
function onNavigateLeft() {
if (!grid.activeFocus) return
grid.currentIndex = Math.max(0, grid.currentIndex - 1)
}
QQC2.BusyIndicator {
anchors.centerIn: parent
running: App.importing
visible: App.importing
function onNavigateRight() {
if (!grid.activeFocus) return
grid.currentIndex = Math.min(proxyModel.count - 1, grid.currentIndex + 1)
}
}
}
}
}

File diff suppressed because it is too large Load diff

1
third-party/mauikit vendored Submodule

@ -0,0 +1 @@
Subproject commit 32dbb346861fa89a1df9dfb82e54ff952754b232

1
third-party/mauiman vendored Submodule

@ -0,0 +1 @@
Subproject commit 6ad438d97e000158aa0eda13ab669c208e804b0a