diff --git a/shell/CMakeLists.txt b/shell/CMakeLists.txt index f1c55fb..e7892c2 100644 --- a/shell/CMakeLists.txt +++ b/shell/CMakeLists.txt @@ -5,13 +5,19 @@ project(nomad-shell VERSION 0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(Qt6 REQUIRED COMPONENTS Quick) +find_package(Qt6 REQUIRED COMPONENTS Quick Sql) qt_standard_project_setup() qt_add_executable(nomad-shell src/main.cpp src/input/InputManager.cpp + src/library/LibraryDatabase.cpp + src/library/GameRecord.h + src/library/GameRepository.h + src/library/GameRepository.cpp + src/library/GameLibraryModel.h + src/library/GameLibraryModel.cpp ) qt_add_qml_module(nomad-shell @@ -24,5 +30,5 @@ qt_add_qml_module(nomad-shell ) target_link_libraries(nomad-shell - PRIVATE Qt6::Quick + PRIVATE Qt6::Quick Qt6::Sql ) \ No newline at end of file diff --git a/shell/qml/GameCard.qml b/shell/qml/GameCard.qml index 3f2febf..c11bca7 100644 --- a/shell/qml/GameCard.qml +++ b/shell/qml/GameCard.qml @@ -4,6 +4,8 @@ Item { id: root property bool highlighted: false + property string title: "" + property url artwork width: 120 height: 180 @@ -20,5 +22,12 @@ Item { Rectangle { anchors.fill: parent color: "white" + + Image { + anchors.fill: parent + + source: root.artwork + fillMode: Image.PreserveAspectCrop + } } } \ No newline at end of file diff --git a/shell/qml/Main.qml b/shell/qml/Main.qml index c92b218..d8c7582 100644 --- a/shell/qml/Main.qml +++ b/shell/qml/Main.qml @@ -80,17 +80,6 @@ QtObject { anchors.fill: parent color: "#181818" - // top text where a menu dock will probably be eventually - Text { - anchors.top: parent.top - anchors.topMargin: 30 - anchors.horizontalCenter: parent.horizontalCenter - - text: "top placeholder" - color: "white" - font.pixelSize: 24 - } - // this is the game carousel. mostly self explanatory, but I wanna point out that the local index for this listView does probably still matter. // I got rid of dedicated menu indexing for the dock, but I think it still matters here, for the sake of auto-scrolling ListView { @@ -117,17 +106,47 @@ QtObject { leftMargin: 16 rightMargin: 16 - model: 20 // this is a placeholder for now, I don't know how I really want to handle max game carousel size yet. another time. + model: gameLibraryModel // provides the smoothing animation for game selection + // TODO: tune this to make it as nice as possible !!! NumberAnimation { id: scrollAnimation target: gamesList property: "contentX" - duration: 200 + duration: 300 easing.type: Easing.OutCubic } + function selectCentermostGame() { + let viewCenter = gamesList.width / 2 + + let bestItem = null + let bestDistance = Infinity + + for (let i = 0; i < gamesList.count; i++) { + let item = gamesList.itemAtIndex(i) + + // I think offscreen items do not exist + if (!item) { + continue + } + + let itemCenter = item.x - gamesList.contentX + item.width / 2 + + let distance = Math.abs(itemCenter - viewCenter) + + if (distance < bestDistance) { + bestDistance = distance + bestItem = item + } + } + + if (bestItem) { + navigationManager.focusItem(bestItem) + } + } + // used to scroll to a new game on the game list function scrollTo(targetX) { if (Math.abs(targetX - gamesList.contentX) < 0.5) { @@ -220,12 +239,16 @@ QtObject { // after flicking, we re-snap the carousel wherever it landed onMovementEnded: { gamesList.scrollTo(gamesList.snappedContentX(gamesList.contentX)) + gamesList.selectCentermostGame() } delegate: GameCard { id: gameTile - highlighted: navigationManager.currentItem === gameTile + title: model.title + artwork: model.gridArtwork + + highlighted: navigationManager.currentItem === gameTile && !gamesList.moving function onNavigationFocused() { gamesList.currentIndex = index @@ -292,6 +315,54 @@ QtObject { } } } + + Text { + id: selectedGameTitle + + text: gamesList.currentItem ? gamesList.currentItem.title : "" + opacity: (navigationManager.currentItem === gamesList.currentItem && !gamesList.moving) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + color: "white" + font.pixelSize: 18 + + elide: Text.ElideRight + horizontalAlignment: Text. AlignHCenter + + width: Math.min(implicitWidth, bottomRoot.width - 32) + + x: { // we wanna make sure that it centers above the game, except when it's at rest and it's close enough to the edge of the screen that it wont fit + if (!gamesList.currentItem) { + return 16 + } + + let cardCenter = gamesList.x + gamesList.currentItem.x - gamesList.contentX + gamesList.currentItem.width / 2 + + let desiredX = cardCenter - width / 2 + + let minimumX = 16 + let maximumX = bottomRoot.width - width - 16 + return Math.max(minimumX, Math.min(desiredX, maximumX)) + } + + Behavior on x { + enabled: !gamesList.moving && !scrollAnimation.running + + NumberAnimation { + duration: 100 + easing.type: Easing.OutCubic + } + } + + anchors.top: gamesList.bottom + anchors.topMargin: 10 + } } } } \ No newline at end of file diff --git a/shell/src/library/GameLibraryModel.cpp b/shell/src/library/GameLibraryModel.cpp new file mode 100644 index 0000000..30723be --- /dev/null +++ b/shell/src/library/GameLibraryModel.cpp @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include "GameLibraryModel.h" + +GameLibraryModel::GameLibraryModel(GameRepository &repository, QObject *parent) : QAbstractListModel(parent), m_repository(repository) { + refresh(); +} + +int GameLibraryModel::rowCount(const QModelIndex &parent) const { + // our database is flat, so return 0 if it's asking for children + if (parent.isValid()) { + return 0; + } + + return m_games.size(); +} + +// fetches artwork based on the game's UUID +QUrl GameLibraryModel::gridArtworkUrl(const QString &gameId) const { + QString dataDirectory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + + QDir artworkDirectory(QDir(dataDirectory).filePath("artwork/" + gameId)); + + const QStringList extensions = { + "png", + "jpg", + "jpeg", + "webp" + }; + + for (const QString &extension : extensions) { + QString artworkPath = artworkDirectory.filePath("grid." + extension); + + if (QFileInfo::exists(artworkPath)) { + return QUrl::fromLocalFile(artworkPath); + } + } + + return {}; +} + +// returns whatever data is requested +QVariant GameLibraryModel::data(const QModelIndex &index, int role) const { + if (!index.isValid()) { + return {}; + } + + if (index.row() < 0 || index.row() >= m_games.size()) { + return {}; + } + + const GameRecord &game = m_games.at(index.row()); + + switch (role) { + case GameIdRole: + return game.id; + + case TitleRole: + return game.title; + + case PlatformRole: + return game.platform; + + case RunnerRole: + return game.runner; + + case SourceRole: + return game.source; + + case GridArtworkRole: + return gridArtworkUrl(game.id); + + default: + return {}; + } +} + +QHash GameLibraryModel::roleNames() const { + return { + { GameIdRole, "gameId" }, + { TitleRole, "title" }, + { PlatformRole, "platform" }, + { RunnerRole, "runner" }, + { SourceRole, "source" }, + { GridArtworkRole, "gridArtwork" } + }; +} + +// Reload all games from the repository safely +bool GameLibraryModel::refresh() { + QString error; + + QList games = m_repository.allGames(&error); + + if (!error.isEmpty()) { + qWarning() << "could not load game library:" << error; + return false; + } + + beginResetModel(); + + m_games = games; + + endResetModel(); + + return true; +} \ No newline at end of file diff --git a/shell/src/library/GameLibraryModel.h b/shell/src/library/GameLibraryModel.h new file mode 100644 index 0000000..c63db7e --- /dev/null +++ b/shell/src/library/GameLibraryModel.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include "GameRecord.h" +#include "GameRepository.h" + +class GameLibraryModel : public QAbstractListModel { + Q_OBJECT + +public: + // lays out the individual bits of data that this can expose + enum Role { + GameIdRole = Qt::UserRole + 1, + TitleRole, + PlatformRole, + RunnerRole, + SourceRole, + GridArtworkRole + }; + + // make sure we get a GameRepository + explicit GameLibraryModel(GameRepository &repository, QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QHash roleNames() const override; + + bool refresh(); + +private: + GameRepository &m_repository; + QList m_games; + QUrl gridArtworkUrl(const QString &gameId) const; +}; \ No newline at end of file diff --git a/shell/src/library/GameRecord.h b/shell/src/library/GameRecord.h new file mode 100644 index 0000000..b97a60b --- /dev/null +++ b/shell/src/library/GameRecord.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +struct GameRecord +{ + QString id; + QString title; + QString platform; + QString runner; + QString source; +}; \ No newline at end of file diff --git a/shell/src/library/GameRepository.cpp b/shell/src/library/GameRepository.cpp new file mode 100644 index 0000000..662b3f7 --- /dev/null +++ b/shell/src/library/GameRepository.cpp @@ -0,0 +1,52 @@ +#include +#include +#include "GameRepository.h" + +// GameRepository is here to handle queries against the games stored in nomad's library database +// We don't wanna let QML touch our database directly, so database logic goes here + +// store a handle to the database connection we got +GameRepository::GameRepository(QSqlDatabase database) : m_database(database) {} + +QList GameRepository::allGames(QString *error) const { + // this list will be populated with a bunch of GameRecords for each row of the query + QList games; + + QSqlQuery query(m_database); + + // fetches every game in the library. for now, it's sorted by title's alphabetically. + // I'll probably keep it this way and do all UI sorting and organization in-house + if (!query.exec(R"( + SELECT + id, + title, + platform, + runner, + source + FROM games + ORDER BY title COLLATE NOCASE; + )")) { + if (error) { + *error = query.lastError().text(); + } + + // return empty list if it fails + return games; + } + + // step through each row and convert it to a GameRecord, adding it to the list + while (query.next()) { + GameRecord game; + + // Columns are numbered in the same order as the SELECT statement above, starting at 0 + game.id = query.value(0).toString(); + game.title = query.value(1).toString(); + game.platform = query.value(2).toString(); + game.runner = query.value(3).toString(); + game.source = query.value(4).toString(); + + games.append(game); + } + + return games; +} \ No newline at end of file diff --git a/shell/src/library/GameRepository.h b/shell/src/library/GameRepository.h new file mode 100644 index 0000000..a84702a --- /dev/null +++ b/shell/src/library/GameRepository.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include "GameRecord.h" + +class GameRepository { +public: + explicit GameRepository(QSqlDatabase database); + + QList allGames(QString *error = nullptr) const; + +private: + QSqlDatabase m_database; +}; \ No newline at end of file diff --git a/shell/src/library/LibraryDatabase.cpp b/shell/src/library/LibraryDatabase.cpp new file mode 100644 index 0000000..be1386f --- /dev/null +++ b/shell/src/library/LibraryDatabase.cpp @@ -0,0 +1,158 @@ +#include +#include +#include +#include +#include +#include "LibraryDatabase.h" + +LibraryDatabase::~LibraryDatabase() { + // close our database when LibraryDatabase is destroyed + if (m_database.isOpen()) { + m_database.close(); + } +} + +bool LibraryDatabase::open() { + // ask Qt for whatever platform-specific appdata directory we've got access to + QString dataDirectory = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + + if (dataDirectory.isEmpty()) { + m_lastError = "could not find application directory"; + + return false; + } + + QDir directory; + + // ensure the directory exists before trying to open the database + if (!directory.mkpath(dataDirectory)) { + m_lastError = "could not create application data directory: " + dataDirectory; + + return false; + } + + // just put the database at the root of our appdata directory + m_databasePath = dataDirectory + "/library.db"; + + // create a named Qt SQL connection with SQLITE + m_database = QSqlDatabase::addDatabase("QSQLITE", "nomad-library"); + + // tell the SQLite connection where it's database is + m_database.setDatabaseName(m_databasePath); + + if (!m_database.open()) { + m_lastError = "could not open library database: " + m_database.lastError().text(); + + return false; + } + + // make sure we've got a schema that this version of nomad understands + if (!initializeSchema()) { + m_database.close(); + return false; + } + + return true; +} + + +bool LibraryDatabase::initializeSchema() { + // newest database schema this version of nomad knows how to use + constexpr int CurrentSchemaVersion = 1; + + // run queries against nomad's database connection + QSqlQuery query(m_database); + + // SQLite's user_version is an application-controlled integer. we'll use it to + // keep track of which version of our schema the database is currently on + if (!query.exec("PRAGMA user_version;")) { + m_lastError = "could not read database schema version: " + query.lastError().text(); + + return false; + } + + // move to the first row returned by the query to read it's value, and set it if it's + // read correctly + if (!query.next()) { + m_lastError = "database didn't return a schema version."; + + return false; + } + + int schemaVersion = + query.value(0).toInt(); + + + // SQLite inits user_version to 0, so version 0 means this database hasn't had the + // nomad initial schema created + if (schemaVersion == 0) { + // make schema creation atomic; if any of it fails, make sure the database is unchanged. + if (!m_database.transaction()) { + m_lastError = "could not begin database initialization transaction."; + + return false; + } + + // initial games table + if (!query.exec(R"( + CREATE TABLE games ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + + platform TEXT NOT NULL, + runner TEXT, + source TEXT NOT NULL + ); + )")) { + m_lastError = "couldn't create games table: " + query.lastError().text(); + + m_database.rollback(); + return false; + } + + // schema creation successful, attempt to mark this database as version 1 + if (!query.exec("PRAGMA user_version = 1;")) { + m_lastError = "could not set database schema version: " + query.lastError().text(); + + m_database.rollback(); + return false; + } + + // permanently apply this transaction, or attempt to + if (!m_database.commit()) { + m_lastError = "could not commit database initialization."; + + m_database.rollback(); + return false; + } + + schemaVersion = 1; + } + + // don't attempt to use a database if it's created with a newer version. + if (schemaVersion > CurrentSchemaVersion) { + m_lastError = "library database schema is newer than this version of nomad supports."; + + return false; + } + + return true; +} + +QSqlDatabase LibraryDatabase::database() const { + return m_database; +} + +bool LibraryDatabase::isOpen() const { + return m_database.isOpen(); +} + + +QString LibraryDatabase::databasePath() const { + return m_databasePath; +} + + +QString LibraryDatabase::lastError() const { + return m_lastError; +} \ No newline at end of file diff --git a/shell/src/library/LibraryDatabase.h b/shell/src/library/LibraryDatabase.h new file mode 100644 index 0000000..5c00232 --- /dev/null +++ b/shell/src/library/LibraryDatabase.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +class LibraryDatabase { +public: + LibraryDatabase() = default; + ~LibraryDatabase(); + + bool open(); + bool isOpen() const; + + QSqlDatabase database() const; + QString databasePath() const; + QString lastError() const; + +private: + bool initializeSchema(); + + QSqlDatabase m_database; + QString m_databasePath; + QString m_lastError; +}; \ No newline at end of file diff --git a/shell/src/main.cpp b/shell/src/main.cpp index 80a894c..dfa2901 100644 --- a/shell/src/main.cpp +++ b/shell/src/main.cpp @@ -2,13 +2,35 @@ #include #include #include +#include #include "input/InputManager.h" +#include "library/LibraryDatabase.h" +#include "library/GameRepository.h" +#include "library/GameLibraryModel.h" int main(int argc, char *argv[]) { // creates our Qt GUI app QGuiApplication app(argc, argv); + // opens nomad's persistent library database, ensures ready schema, etc + LibraryDatabase libraryDatabase; + + if (!libraryDatabase.open()) { + qCritical() << "Failed to open nomad library:" << libraryDatabase.lastError(); + + return -1; + } + + // GameRepository hadnles reading our game records from the database, while + // GameLibraryModel exposes those records in a QML-friendly list model + GameRepository gameRepository(libraryDatabase.database()); + GameLibraryModel gameLibraryModel(gameRepository); + + // database diagnostics + qDebug() << "Library database:" << libraryDatabase.databasePath(); + qDebug() << "Games loaded:" << gameLibraryModel.rowCount(); + // creats InputManager and installs it as an application-wide event filter so it can observe input events before normal delivery InputManager inputManager; app.installEventFilter(&inputManager); @@ -22,6 +44,7 @@ int main(int argc, char *argv[]) QQmlApplicationEngine engine; // make our InputManager instance available to QML for signals engine.rootContext()->setContextProperty("inputManager", &inputManager); + engine.rootContext()->setContextProperty("gameLibraryModel", &gameLibraryModel); // load our Main.qml into the engine to start things off engine.loadFromModule("nomad", "Main");