From 1e3f772b075ac88b671995f0bead49d330968efe Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:40:19 -0400 Subject: [PATCH] feat(deck): bind selected host game preview --- clients/deck/qml/Main.qml | 130 +++++++++++++++++++----- clients/deck/src/deck_layout.cpp | 82 +++++++++++++-- clients/deck/src/deck_layout.h | 19 ++++ clients/deck/src/main.cpp | 38 ++++--- clients/deck/tests/deck_layout_test.cpp | 47 +++++++++ 5 files changed, 269 insertions(+), 47 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 22563853..33a19254 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -25,21 +25,80 @@ ApplicationWindow { readonly property int sampleTextWidth: sampleCardWidth - 48 readonly property int detailTextWidth: detailColumnWidth - 48 property int previewCopyActivationCount: 0 + property var selectedHostForPreview: novaSelectedHostDetail + property var selectedGameForPreview: novaSelectedGameCard + property string selectedLaunchPreviewText: novaSelectedLaunchPreviewText + property var launchPreviewCopyAction: novaLaunchPreviewCopyAction + + function selectedHostSubtitle() { + return "Demo host detail only — not discovered from the network." + } + + function previewComponent(value) { + return encodeURIComponent(value === undefined || value === null ? "" : String(value)) + } + + function refreshLaunchPreviewBinding() { + const hostId = selectedHostForPreview && selectedHostForPreview.id + ? selectedHostForPreview.id + : "host-empty-state" + const gameTitle = selectedGameForPreview && selectedGameForPreview.title + ? selectedGameForPreview.title + : "No game selected" + selectedLaunchPreviewText = "preview://nova-deck/launch?host=" + + previewComponent(hostId) + + "&game=" + + previewComponent(gameTitle) + + "&state=copy-preview-only" + launchPreviewCopyAction = { + "id": novaLaunchPreviewCopyAction.id, + "label": novaLaunchPreviewCopyAction.label, + "previewText": selectedLaunchPreviewText, + "idleStatusLabel": novaLaunchPreviewCopyAction.idleStatusLabel, + "successToast": novaLaunchPreviewCopyAction.successToast, + "inertToast": novaLaunchPreviewCopyAction.inertToast, + "enabled": selectedLaunchPreviewText.length > 0, + "copyOnly": true, + "uiLocalClipboardOnly": true, + "executable": false + } + } + + function selectHostForPreview(hostModel) { + selectedHostForPreview = { + "id": hostModel.id, + "displayName": hostModel.displayName, + "statusLabel": hostModel.statusLabel, + "subtitle": selectedHostSubtitle() + } + refreshLaunchPreviewBinding() + } + + function selectGameForPreview(gameModel) { + selectedGameForPreview = { + "id": gameModel.id, + "title": gameModel.title, + "sourceRuntimeLabel": gameModel.sourceRuntimeLabel, + "launchModeLabel": gameModel.launchModeLabel, + "installedLabel": gameModel.installedLabel + } + refreshLaunchPreviewBinding() + } function activateLaunchPreviewCopyFromController() { - const canCopyPreview = novaLaunchPreviewCopyAction.enabled - && novaLaunchPreviewCopyAction.previewText.length > 0 - && novaLaunchPreviewCopyAction.copyOnly - && novaLaunchPreviewCopyAction.uiLocalClipboardOnly - && !novaLaunchPreviewCopyAction.executable + const canCopyPreview = launchPreviewCopyAction.enabled + && launchPreviewCopyAction.previewText.length > 0 + && launchPreviewCopyAction.copyOnly + && launchPreviewCopyAction.uiLocalClipboardOnly + && !launchPreviewCopyAction.executable const didCopyPreview = canCopyPreview - && novaLocalClipboard.copyPreviewText(novaLaunchPreviewCopyAction.previewText) + && novaLocalClipboard.copyPreviewText(launchPreviewCopyAction.previewText) if (didCopyPreview) { previewCopyActivationCount += 1 } copyStatusLabel.text = didCopyPreview - ? novaLaunchPreviewCopyAction.successToast + " · A pressed #" + previewCopyActivationCount - : novaLaunchPreviewCopyAction.inertToast + " · A press stayed preview-only" + ? launchPreviewCopyAction.successToast + " · A pressed #" + previewCopyActivationCount + : launchPreviewCopyAction.inertToast + " · A press stayed preview-only" copyStatusLabel.color = didCopyPreview ? "#8AFFC1" : "#FFDDA8" } @@ -63,6 +122,7 @@ ApplicationWindow { anchors.fill: parent focus: true Component.onCompleted: Qt.callLater(function() { + refreshLaunchPreviewBinding() if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() } else { @@ -157,13 +217,20 @@ ApplicationWindow { Layout.preferredWidth: hostColumnWidth Layout.preferredHeight: hostCardHeight radius: 20 - color: activeFocus ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : "#7C73FF" - border.width: activeFocus ? 5 : 3 + color: selectedHostForPreview.id === modelData.id ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : selectedHostForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" + border.width: activeFocus ? 5 : selectedHostForPreview.id === modelData.id ? 4 : 3 focus: modelData.initialFocus activeFocusOnTab: true KeyNavigation.right: hostDetailPanel - Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + onActiveFocusChanged: if (activeFocus) selectHostForPreview(modelData) + Keys.onRightPressed: { + selectHostForPreview(modelData) + hostDetailPanel.forceActiveFocus() + } + Keys.onReturnPressed: selectHostForPreview(modelData) + Keys.onEnterPressed: selectHostForPreview(modelData) + Keys.onSpacePressed: selectHostForPreview(modelData) Keys.onDownPressed: { const next = hostRepeater.itemAt(index + 1) if (next !== null) { @@ -232,13 +299,20 @@ ApplicationWindow { Layout.preferredWidth: sampleCardWidth Layout.preferredHeight: 88 radius: 18 - color: activeFocus ? "#202B55" : "#151D39" - border.color: activeFocus ? "#B8C2FF" : "#7C73FF" - border.width: activeFocus ? 5 : 2 + color: selectedGameForPreview.id === modelData.id ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : selectedGameForPreview.id === modelData.id ? "#8AFFC1" : "#7C73FF" + border.width: activeFocus ? 5 : selectedGameForPreview.id === modelData.id ? 4 : 2 focus: modelData.initialFocus activeFocusOnTab: true KeyNavigation.right: hostDetailPanel - Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + onActiveFocusChanged: if (activeFocus) selectGameForPreview(modelData) + Keys.onRightPressed: { + selectGameForPreview(modelData) + hostDetailPanel.forceActiveFocus() + } + Keys.onReturnPressed: selectGameForPreview(modelData) + Keys.onEnterPressed: selectGameForPreview(modelData) + Keys.onSpacePressed: selectGameForPreview(modelData) Keys.onDownPressed: { const next = libraryGameRepeater.itemAt(index + 1) if (next !== null) { @@ -325,25 +399,33 @@ ApplicationWindow { } Label { - text: novaSelectedHostDetail.displayName + text: selectedHostForPreview.displayName color: "#E9ECFF" font.pixelSize: 30 font.bold: true } Label { - text: novaSelectedHostDetail.statusLabel + text: selectedHostForPreview.statusLabel color: "#B8C2F0" font.pixelSize: 19 } Label { Layout.preferredWidth: detailTextWidth - text: novaSelectedHostDetail.subtitle + text: selectedHostForPreview.subtitle color: "#A8B0D8" font.pixelSize: 16 wrapMode: Text.WordWrap } + + Label { + Layout.preferredWidth: detailTextWidth + text: "Selected game: " + selectedGameForPreview.title + color: "#8AFFC1" + font.pixelSize: 14 + wrapMode: Text.WordWrap + } } } @@ -421,7 +503,7 @@ ApplicationWindow { Label { Layout.preferredWidth: detailTextWidth - text: novaHostLaunchCta.previewText + text: selectedLaunchPreviewText color: "#A8B0D8" font.pixelSize: 12 font.family: "monospace" @@ -430,9 +512,9 @@ ApplicationWindow { Button { id: copyPreviewButton - objectName: novaLaunchPreviewCopyAction.id - text: activeFocus ? "A · " + novaLaunchPreviewCopyAction.label : novaLaunchPreviewCopyAction.label - enabled: novaLaunchPreviewCopyAction.enabled + objectName: launchPreviewCopyAction.id + text: activeFocus ? "A · " + launchPreviewCopyAction.label : launchPreviewCopyAction.label + enabled: launchPreviewCopyAction.enabled focusPolicy: Qt.StrongFocus activeFocusOnTab: true KeyNavigation.up: launchCtaPlaceholder @@ -453,7 +535,7 @@ ApplicationWindow { Label { id: copyStatusLabel Layout.preferredWidth: detailTextWidth - text: novaLaunchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify." + text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify." color: "#FFDDA8" font.pixelSize: 12 wrapMode: Text.WordWrap diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 9675ee88..4a780cef 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -73,6 +73,46 @@ std::string launchModeLabelFor(const PolarisGameFixture& game) { return "Stream: " + streamMode + " · Steam: " + steamMode; } +DeckLibraryGameCard libraryGameCardFor(const PolarisGameFixture& game, const int row) { + return DeckLibraryGameCard{ + .id = game.id.empty() ? "library-game-" + std::to_string(row) : game.id, + .title = game.name.empty() ? "Untitled game" : game.name, + .sourceRuntimeLabel = joinedSourceRuntimeLabelFor(game), + .launchModeLabel = launchModeLabelFor(game), + .installedLabel = game.installed ? "Installed" : "Not installed", + .row = row, + .initialFocus = row == 0, + }; +} + +std::size_t selectedGameIndexFor(const PolarisGameLibraryFixture& library, const std::string_view selectedGameId) { + if (library.games.empty()) { + return 0; + } + + const auto selected = std::ranges::find_if(library.games, [selectedGameId](const PolarisGameFixture& game) { + return game.id == selectedGameId; + }); + + if (selected == library.games.end()) { + return 0; + } + + return static_cast(std::distance(library.games.begin(), selected)); +} + +PolarisGameFixture emptySelectedGameFixture() { + return PolarisGameFixture{ + .id = "game-empty-state", + .name = "No game selected", + .source = "manual", + .launcherSource = "manual", + .platformLabel = "Preview", + .runtimeLabel = "No runtime", + .installed = false, + }; +} + } // namespace @@ -174,15 +214,7 @@ std::vector libraryGameCardsFor(const PolarisGameLibraryFix cards.reserve(library.games.size()); int row = 0; for (const auto& game : library.games) { - cards.push_back(DeckLibraryGameCard{ - .id = game.id.empty() ? "library-game-" + std::to_string(row) : game.id, - .title = game.name.empty() ? "Untitled game" : game.name, - .sourceRuntimeLabel = joinedSourceRuntimeLabelFor(game), - .launchModeLabel = launchModeLabelFor(game), - .installedLabel = game.installed ? "Installed" : "Not installed", - .row = row, - .initialFocus = row == 0, - }); + cards.push_back(libraryGameCardFor(game, row)); ++row; } return cards; @@ -286,6 +318,38 @@ DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const Polaris }; } +DeckLaunchPreviewBinding resolveLaunchPreviewBinding( + const std::vector& hosts, + const PolarisGameLibraryFixture& library, + const std::string_view selectedHostId, + const std::string_view selectedGameId) { + const std::string_view resolvedHostId = resolveHostDetail(hosts, selectedHostId).id == std::string_view("host-detail-empty") + ? initialHostFocusTarget(hosts) + : selectedHostId; + const auto hostDetail = resolveHostDetail(hosts, resolvedHostId); + + const auto gameIndex = selectedGameIndexFor(library, selectedGameId); + const auto selectedGame = library.games.empty() ? emptySelectedGameFixture() : library.games.at(gameIndex); + const auto gameCard = libraryGameCardFor(selectedGame, static_cast(gameIndex)); + const auto intent = resolveLaunchIntent(hostDetail, selectedGame); + const auto preview = fakeLaunchCommandPreviewFor(intent); + const auto launchCta = inertLaunchCtaFor(hostDetail, selectedGame); + const auto copyAction = copyLaunchPreviewActionFor(preview); + + return DeckLaunchPreviewBinding{ + .selectedHostId = std::string(hostDetail.id), + .selectedHostName = std::string(hostDetail.displayName), + .selectedGameId = selectedGame.id, + .selectedGameTitle = selectedGame.name, + .hostDetail = hostDetail, + .gameCard = gameCard, + .intent = intent, + .preview = preview, + .launchCta = launchCta, + .copyAction = copyAction, + }; +} + bool canExecuteLaunchIntent(const DeckLaunchIntent& intent) { return intent.executable && !intent.boundary.previewOnly diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 01870c5a..7c928d55 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -119,6 +119,19 @@ struct DeckLaunchPreviewCopyResult { bool copied = false; }; +struct DeckLaunchPreviewBinding { + std::string selectedHostId; + std::string selectedHostName; + std::string selectedGameId; + std::string selectedGameTitle; + DeckHostDetail hostDetail; + DeckLibraryGameCard gameCard; + DeckLaunchIntent intent; + DeckLaunchPreview preview; + DeckLaunchCta launchCta; + DeckLaunchPreviewCopyAction copyAction; +}; + class DeckLocalClipboard { public: virtual ~DeckLocalClipboard() = default; @@ -162,6 +175,12 @@ DeckLaunchIntentBoundary previewOnlyLaunchIntentBoundary(); DeckLaunchIntent resolveLaunchIntent(const DeckHostDetail& detail, const PolarisGameFixture& game); +DeckLaunchPreviewBinding resolveLaunchPreviewBinding( + const std::vector& hosts, + const PolarisGameLibraryFixture& library, + std::string_view selectedHostId, + std::string_view selectedGameId); + bool canExecuteLaunchIntent(const DeckLaunchIntent& intent); DeckLaunchPreview fakeLaunchCommandPreviewFor(const DeckLaunchIntent& intent); diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 1ef46af7..8ebe7051 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -161,17 +161,21 @@ QVariantMap toHostDetailModel(const nova::deck::DeckHostDetail& detail) { return model; } +QVariantMap toLibraryGameCardModel(const nova::deck::DeckLibraryGameCard& game) { + QVariantMap item; + item.insert("id", toQString(game.id)); + item.insert("title", toQString(game.title)); + item.insert("sourceRuntimeLabel", toQString(game.sourceRuntimeLabel)); + item.insert("launchModeLabel", toQString(game.launchModeLabel)); + item.insert("installedLabel", toQString(game.installedLabel)); + item.insert("initialFocus", game.initialFocus); + return item; +} + QVariantList toLibraryGameModel(const std::vector& games) { QVariantList model; for (const auto& game : games) { - QVariantMap item; - item.insert("id", toQString(game.id)); - item.insert("title", toQString(game.title)); - item.insert("sourceRuntimeLabel", toQString(game.sourceRuntimeLabel)); - item.insert("launchModeLabel", toQString(game.launchModeLabel)); - item.insert("installedLabel", toQString(game.installedLabel)); - item.insert("initialFocus", game.initialFocus); - model.append(item); + model.append(toLibraryGameCardModel(game)); } return model; } @@ -231,13 +235,17 @@ int main(int argc, char *argv[]) { const auto profile = nova::deck::defaultWindowProfile(); const auto sampleLibrary = nova::deck::loadSamplePolarisGameLibraryFixture(); const auto libraryGames = nova::deck::libraryGameCardsFor(sampleLibrary); - const auto& selectedGame = sampleLibrary.games.front(); const auto demoHosts = nova::deck::demoHostListState(); - const auto selectedHostDetail = nova::deck::resolveHostDetail(demoHosts, nova::deck::initialHostFocusTarget(demoHosts)); - const auto launchIntent = nova::deck::resolveLaunchIntent(selectedHostDetail, selectedGame); - const auto launchPreview = nova::deck::fakeLaunchCommandPreviewFor(launchIntent); - const auto launchCta = nova::deck::inertLaunchCtaFor(selectedHostDetail, selectedGame); - const auto launchPreviewCopyAction = nova::deck::copyLaunchPreviewActionFor(launchPreview); + const std::string initialGameId = sampleLibrary.games.empty() ? std::string{} : sampleLibrary.games.front().id; + const auto selectedBinding = nova::deck::resolveLaunchPreviewBinding( + demoHosts, + sampleLibrary, + nova::deck::initialHostFocusTarget(demoHosts), + initialGameId); + const auto& selectedHostDetail = selectedBinding.hostDetail; + const auto& launchIntent = selectedBinding.intent; + const auto& launchCta = selectedBinding.launchCta; + const auto& launchPreviewCopyAction = selectedBinding.copyAction; QtLocalClipboardBridge localClipboard; QtDeckGamepadBridge gamepadBridge; @@ -252,6 +260,8 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaLibraryGames", toLibraryGameModel(libraryGames)); engine.rootContext()->setContextProperty("novaDemoHosts", toHostModel(demoHosts)); engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); + engine.rootContext()->setContextProperty("novaSelectedGameCard", toLibraryGameCardModel(selectedBinding.gameCard)); + engine.rootContext()->setContextProperty("novaSelectedLaunchPreviewText", toQString(selectedBinding.preview.text)); engine.rootContext()->setContextProperty("novaHostLaunchCta", toLaunchCtaModel(launchCta)); engine.rootContext()->setContextProperty("novaLaunchIntentBoundary", toLaunchIntentBoundaryModel(launchIntent.boundary)); engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index dd480871..7797883a 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -78,6 +78,10 @@ int main() { assert(mainQml.find("novaLaunchIntentBoundary") != std::string::npos); assert(mainQml.find("Typed launch boundary") != std::string::npos); assert(mainQml.find("network/process/Moonlight blocked") != std::string::npos); + assert(mainQml.find("selectedHostForPreview") != std::string::npos); + assert(mainQml.find("selectedGameForPreview") != std::string::npos); + assert(mainQml.find("refreshLaunchPreviewBinding") != std::string::npos); + assert(mainQml.find("selectedLaunchPreviewText") != std::string::npos); assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ .timeMs = 10, @@ -170,6 +174,49 @@ int main() { assert(launchIntent.safetyLabel == "Preview only — not executable"); assert(!nova::deck::canExecuteLaunchIntent(launchIntent)); + const auto previewLibrary = nova::deck::loadSamplePolarisGameLibraryFixture(); + const auto selectedBinding = nova::deck::resolveLaunchPreviewBinding( + demoHosts, + previewLibrary, + "host-living-room-pc", + "game-456"); + assert(selectedBinding.selectedHostId == "host-living-room-pc"); + assert(selectedBinding.selectedHostName == "Living Room PC"); + assert(selectedBinding.selectedGameId == "game-456"); + assert(selectedBinding.selectedGameTitle == "Hades"); + assert(selectedBinding.hostDetail.id == std::string_view("host-living-room-pc")); + assert(selectedBinding.gameCard.title == "Hades"); + assert(selectedBinding.intent.targetHostId == "host-living-room-pc"); + assert(selectedBinding.intent.targetHostName == "Living Room PC"); + assert(selectedBinding.intent.sampleGameId == "game-456"); + assert(selectedBinding.intent.gameTitle == "Hades"); + assert(selectedBinding.intent.streamLaunchMode == "virtual_display"); + assert(selectedBinding.intent.steamLaunchMode == "big-picture"); + assert(!selectedBinding.intent.executable); + assert(!nova::deck::canExecuteLaunchIntent(selectedBinding.intent)); + assert(selectedBinding.preview.text == "preview://nova-deck/launch?host=host-living-room-pc&game=Hades&state=copy-preview-only"); + assert(selectedBinding.preview.copyOnly); + assert(!selectedBinding.preview.executable); + assert(!selectedBinding.preview.networkAllowed); + assert(!selectedBinding.preview.processExecutionAllowed); + assert(!selectedBinding.preview.moonlightAllowed); + assert(!selectedBinding.preview.hostMutationAllowed); + assert(selectedBinding.launchCta.previewText == selectedBinding.preview.text); + assert(!selectedBinding.launchCta.enabled); + assert(selectedBinding.copyAction.previewText == selectedBinding.preview.text); + assert(selectedBinding.copyAction.enabled); + assert(selectedBinding.copyAction.copyOnly); + assert(!selectedBinding.copyAction.executable); + + const auto fallbackBinding = nova::deck::resolveLaunchPreviewBinding( + demoHosts, + previewLibrary, + "missing-host", + "missing-game"); + assert(fallbackBinding.selectedHostId == "host-gaming-pc"); + assert(fallbackBinding.selectedGameId == "game-123"); + assert(fallbackBinding.preview.text == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&state=copy-preview-only"); + const auto commandPreview = nova::deck::fakeLaunchCommandPreviewFor(launchIntent); assert(commandPreview.text == "preview://nova-deck/launch?host=host-gaming-pc&game=Portal%202&state=copy-preview-only"); assert(commandPreview.stateLabel == "Preview only — not executable");