From 72a7295ba722998377899634f325adb6203e5bf6 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:25:17 -0400 Subject: [PATCH 1/2] Deck read-only library host snapshot --- .../deck/fixtures/sample_polaris_library.json | 44 ++++++++++++++++--- clients/deck/qml/Main.qml | 20 ++++----- clients/deck/src/deck_layout.cpp | 17 +++++++ clients/deck/src/deck_layout.h | 2 + clients/deck/src/main.cpp | 10 ++--- clients/deck/src/polaris_game_fixture.cpp | 13 ++++++ clients/deck/src/polaris_game_fixture.h | 8 ++++ clients/deck/tests/deck_layout_test.cpp | 14 ++++++ 8 files changed, 107 insertions(+), 21 deletions(-) diff --git a/clients/deck/fixtures/sample_polaris_library.json b/clients/deck/fixtures/sample_polaris_library.json index 4aac8652..dfbf4721 100644 --- a/clients/deck/fixtures/sample_polaris_library.json +++ b/clients/deck/fixtures/sample_polaris_library.json @@ -17,21 +17,30 @@ "category": "fast_action", "installed": true, "cover_url": "/polaris/v1/games/game-123/cover", - "genres": ["Action", "Puzzle"], + "genres": [ + "Action", + "Puzzle" + ], "last_launched": 1718187600000, "mangohud": true, "hdr_supported": true, "launch_mode": { "preferred_mode": "virtual_display", "recommended_mode": "headless", - "allowed_modes": ["headless", "virtual_display"], + "allowed_modes": [ + "headless", + "virtual_display" + ], "mode_reason": "Host default is headless." }, "steam_launch": { "available": true, "mode": "big-picture", "recommended_mode": "direct", - "allowed_modes": ["direct", "big-picture"], + "allowed_modes": [ + "direct", + "big-picture" + ], "mode_reason": "Steam Input fallback." } }, @@ -50,23 +59,46 @@ "category": "fast_action", "installed": true, "cover_url": "/polaris/v1/games/game-456/cover", - "genres": ["Action", "Roguelike"], + "genres": [ + "Action", + "Roguelike" + ], "last_launched": 1718191200000, "mangohud": true, "hdr_supported": false, "launch_mode": { "preferred_mode": "headless", "recommended_mode": "virtual_display", - "allowed_modes": ["headless", "virtual_display"], + "allowed_modes": [ + "headless", + "virtual_display" + ], "mode_reason": "Virtual display is available for this host." }, "steam_launch": { "available": true, "mode": "direct", "recommended_mode": "big-picture", - "allowed_modes": ["direct", "big-picture"], + "allowed_modes": [ + "direct", + "big-picture" + ], "mode_reason": "Big Picture is controller-friendly." } } + ], + "hosts": [ + { + "id": "host-snapshot-primary", + "display_name": "Polaris Snapshot Primary", + "status_label": "Ready from read-only library snapshot", + "subtitle": "Read-only host snapshot fixture \u2014 not discovered from the network." + }, + { + "id": "host-snapshot-living-room", + "display_name": "Polaris Snapshot Living Room", + "status_label": "Available from read-only library snapshot", + "subtitle": "Read-only host snapshot fixture \u2014 not discovered from the network." + } ] } diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 33a19254..18d8675b 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -31,7 +31,7 @@ ApplicationWindow { property var launchPreviewCopyAction: novaLaunchPreviewCopyAction function selectedHostSubtitle() { - return "Demo host detail only — not discovered from the network." + return "Read-only host detail only — not discovered from the network." } function previewComponent(value) { @@ -123,7 +123,7 @@ ApplicationWindow { focus: true Component.onCompleted: Qt.callLater(function() { refreshLaunchPreviewBinding() - if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() } else { emptyHostState.forceActiveFocus() @@ -164,7 +164,7 @@ ApplicationWindow { spacing: deckPanelSpacing Label { - text: "Demo hosts" + text: "Library hosts" color: "#E9ECFF" font.pixelSize: 28 font.bold: true @@ -173,7 +173,7 @@ ApplicationWindow { Rectangle { id: emptyHostState objectName: "host-empty-state" - visible: novaDemoHosts.length === 0 + visible: novaLibraryHosts.length === 0 Layout.preferredWidth: hostColumnWidth Layout.preferredHeight: visible ? 120 : 0 radius: 20 @@ -207,7 +207,7 @@ ApplicationWindow { Repeater { id: hostRepeater - model: novaDemoHosts + model: novaLibraryHosts delegate: Rectangle { required property int index @@ -326,7 +326,7 @@ ApplicationWindow { } } Keys.onLeftPressed: { - if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() } else { emptyHostState.forceActiveFocus() @@ -379,7 +379,7 @@ ApplicationWindow { KeyNavigation.left: hostRepeater.itemAt(0) !== null ? hostRepeater.itemAt(0) : emptyHostState KeyNavigation.down: launchCtaPlaceholder Keys.onLeftPressed: { - if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() } else { emptyHostState.forceActiveFocus() @@ -393,7 +393,7 @@ ApplicationWindow { spacing: 8 Label { - text: "Demo host detail" + text: "Read-only host detail" color: "#7C88B8" font.pixelSize: 16 } @@ -449,7 +449,7 @@ ApplicationWindow { Keys.onEnterPressed: activateLaunchPreviewCopyFromController() Keys.onSpacePressed: activateLaunchPreviewCopyFromController() Keys.onLeftPressed: { - if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() } else { emptyHostState.forceActiveFocus() @@ -520,7 +520,7 @@ ApplicationWindow { KeyNavigation.up: launchCtaPlaceholder Keys.onUpPressed: launchCtaPlaceholder.forceActiveFocus() Keys.onLeftPressed: { - if (novaDemoHosts.length > 0 && hostRepeater.itemAt(0) !== null) { + if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { hostRepeater.itemAt(0).forceActiveFocus() } else { emptyHostState.forceActiveFocus() diff --git a/clients/deck/src/deck_layout.cpp b/clients/deck/src/deck_layout.cpp index 4a780cef..b2e644fe 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -209,6 +209,23 @@ std::vector demoHostListState() { }; } +std::vector libraryHostListStateFor(const PolarisGameLibraryFixture& library) { + std::vector hosts; + hosts.reserve(library.hosts.size()); + int row = 0; + for (const auto& host : library.hosts) { + hosts.push_back(DeckHostListItem{ + .id = host.id.empty() ? std::string_view("library-host") : std::string_view(host.id), + .displayName = host.displayName.empty() ? std::string_view("Read-only library host") : std::string_view(host.displayName), + .statusLabel = host.statusLabel.empty() ? std::string_view("Available from read-only library snapshot") : std::string_view(host.statusLabel), + .row = row, + .initialFocus = row == 0, + }); + ++row; + } + return hosts; +} + std::vector libraryGameCardsFor(const PolarisGameLibraryFixture& library) { std::vector cards; cards.reserve(library.games.size()); diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 7c928d55..079d5e63 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -160,6 +160,8 @@ std::vector emptyHostListState(); std::vector demoHostListState(); +std::vector libraryHostListStateFor(const PolarisGameLibraryFixture& library); + std::vector libraryGameCardsFor(const PolarisGameLibraryFixture& library); std::string_view initialHostFocusTarget(const std::vector& hosts); diff --git a/clients/deck/src/main.cpp b/clients/deck/src/main.cpp index 8ebe7051..71df9d27 100644 --- a/clients/deck/src/main.cpp +++ b/clients/deck/src/main.cpp @@ -235,12 +235,12 @@ 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 demoHosts = nova::deck::demoHostListState(); + const auto libraryHosts = nova::deck::libraryHostListStateFor(sampleLibrary); const std::string initialGameId = sampleLibrary.games.empty() ? std::string{} : sampleLibrary.games.front().id; const auto selectedBinding = nova::deck::resolveLaunchPreviewBinding( - demoHosts, + libraryHosts, sampleLibrary, - nova::deck::initialHostFocusTarget(demoHosts), + nova::deck::initialHostFocusTarget(libraryHosts), initialGameId); const auto& selectedHostDetail = selectedBinding.hostDetail; const auto& launchIntent = selectedBinding.intent; @@ -258,7 +258,7 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaLibraryFixtureSource", toQString(sampleLibrary.sourceLabel)); engine.rootContext()->setContextProperty("novaLibraryReadOnly", sampleLibrary.readOnly); engine.rootContext()->setContextProperty("novaLibraryGames", toLibraryGameModel(libraryGames)); - engine.rootContext()->setContextProperty("novaDemoHosts", toHostModel(demoHosts)); + engine.rootContext()->setContextProperty("novaLibraryHosts", toHostModel(libraryHosts)); engine.rootContext()->setContextProperty("novaSelectedHostDetail", toHostDetailModel(selectedHostDetail)); engine.rootContext()->setContextProperty("novaSelectedGameCard", toLibraryGameCardModel(selectedBinding.gameCard)); engine.rootContext()->setContextProperty("novaSelectedLaunchPreviewText", toQString(selectedBinding.preview.text)); @@ -267,7 +267,7 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("novaLaunchPreviewCopyAction", toPreviewCopyActionModel(launchPreviewCopyAction)); engine.rootContext()->setContextProperty("novaLocalClipboard", &localClipboard); engine.rootContext()->setContextProperty("novaGamepad", &gamepadBridge); - engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(demoHosts))); + engine.rootContext()->setContextProperty("novaInitialHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(libraryHosts))); engine.rootContext()->setContextProperty("novaEmptyHostFocusTarget", toQString(nova::deck::initialHostFocusTarget(nova::deck::emptyHostListState()))); const bool smokeExit = QCoreApplication::arguments().contains("--smoke-exit"); diff --git a/clients/deck/src/polaris_game_fixture.cpp b/clients/deck/src/polaris_game_fixture.cpp index 02321d89..671b4470 100644 --- a/clients/deck/src/polaris_game_fixture.cpp +++ b/clients/deck/src/polaris_game_fixture.cpp @@ -248,6 +248,15 @@ std::vector readObjectArray(const std::string& json, const std::str throw std::runtime_error("Unterminated object array in Polaris fixture: " + std::string(key)); } +PolarisHostFixture parsePolarisHostFixtureJson(const std::string& json) { + PolarisHostFixture host; + host.id = readString(json, "id"); + host.displayName = readString(json, "display_name"); + host.statusLabel = readString(json, "status_label"); + host.subtitle = readOptionalString(json, "subtitle", "Read-only host snapshot fixture — not discovered from the network."); + return host; +} + PolarisGameFixture parsePolarisGameFixtureJson(const std::string& json) { const auto launchModeStart = objectStart(json, "launch_mode"); const auto steamLaunchStart = objectStart(json, "steam_launch"); @@ -320,6 +329,10 @@ PolarisGameLibraryFixture loadPolarisGameLibraryFixture(const std::filesystem::p library.sourceLabel = readOptionalString(json, "fixture_source", "Shared Polaris contract fixture"); library.readOnly = readOptionalBool(json, "read_only", true); + for (const auto& objectJson : readObjectArray(json, "hosts")) { + library.hosts.push_back(parsePolarisHostFixtureJson(objectJson)); + } + for (const auto& objectJson : readObjectArray(json, "games")) { library.games.push_back(parsePolarisGameFixtureJson(objectJson)); } diff --git a/clients/deck/src/polaris_game_fixture.h b/clients/deck/src/polaris_game_fixture.h index 2cd15f4c..466d5c81 100644 --- a/clients/deck/src/polaris_game_fixture.h +++ b/clients/deck/src/polaris_game_fixture.h @@ -22,6 +22,13 @@ struct PolarisSteamLaunchFixture { std::string modeReason; }; +struct PolarisHostFixture { + std::string id; + std::string displayName; + std::string statusLabel; + std::string subtitle; +}; + struct PolarisGameFixture { std::string id; int appId = 0; @@ -48,6 +55,7 @@ struct PolarisGameFixture { struct PolarisGameLibraryFixture { std::string sourceLabel; bool readOnly = true; + std::vector hosts; std::vector games; }; diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index 7797883a..c04311fc 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -73,6 +73,7 @@ int main() { assert(mainQml.find("target: novaGamepad") != std::string::npos); assert(mainQml.find("onPrimaryActionPressed") != std::string::npos); assert(mainQml.find("novaLibraryGames") != std::string::npos); + assert(mainQml.find("novaLibraryHosts") != std::string::npos); assert(mainQml.find("libraryGameRepeater") != std::string::npos); assert(mainQml.find("Read-only Polaris library") != std::string::npos); assert(mainQml.find("novaLaunchIntentBoundary") != std::string::npos); @@ -132,6 +133,19 @@ int main() { assert(demoHosts[1].id == std::string_view("host-living-room-pc")); assert(demoHosts[1].displayName == std::string_view("Living Room PC")); assert(nova::deck::initialHostFocusTarget(demoHosts) == std::string_view("host-gaming-pc")); + + const auto realLibrary = nova::deck::loadSamplePolarisGameLibraryFixture(); + const auto libraryHosts = nova::deck::libraryHostListStateFor(realLibrary); + assert(libraryHosts.size() == 2); + assert(libraryHosts[0].id == std::string_view("host-snapshot-primary")); + assert(libraryHosts[0].displayName == std::string_view("Polaris Snapshot Primary")); + assert(libraryHosts[0].statusLabel == std::string_view("Ready from read-only library snapshot")); + assert(libraryHosts[0].initialFocus); + assert(libraryHosts[1].id == std::string_view("host-snapshot-living-room")); + assert(libraryHosts[1].displayName == std::string_view("Polaris Snapshot Living Room")); + assert(libraryHosts[1].statusLabel == std::string_view("Available from read-only library snapshot")); + assert(!libraryHosts[1].initialFocus); + assert(nova::deck::initialHostFocusTarget(libraryHosts) == std::string_view("host-snapshot-primary")); assert(nova::deck::nextHostFocusTarget(demoHosts, "host-gaming-pc", nova::deck::DeckFocusDirection::Down) == std::string_view("host-living-room-pc")); assert(nova::deck::nextHostFocusTarget(demoHosts, "host-living-room-pc", nova::deck::DeckFocusDirection::Up) From 3e146881e9bd2f57279276f55b4acd534e196af5 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:26:25 -0400 Subject: [PATCH 2/2] Polish Deck read-only library focus --- clients/deck/qml/Main.qml | 133 ++++++++++++++++++------ clients/deck/src/deck_layout.cpp | 73 ++++++++++++- clients/deck/src/deck_layout.h | 7 ++ clients/deck/tests/deck_layout_test.cpp | 26 ++++- 4 files changed, 200 insertions(+), 39 deletions(-) diff --git a/clients/deck/qml/Main.qml b/clients/deck/qml/Main.qml index 18d8675b..d716d5f1 100644 --- a/clients/deck/qml/Main.qml +++ b/clients/deck/qml/Main.qml @@ -85,6 +85,36 @@ ApplicationWindow { refreshLaunchPreviewBinding() } + function focusSelectedLibraryItem() { + for (let i = 0; i < libraryGameRepeater.count; ++i) { + const gameItem = libraryGameRepeater.itemAt(i) + if (gameItem !== null && selectedGameForPreview && gameItem.objectName === selectedGameForPreview.id) { + gameItem.forceActiveFocus() + return + } + } + for (let i = 0; i < hostRepeater.count; ++i) { + const hostItem = hostRepeater.itemAt(i) + if (hostItem !== null && selectedHostForPreview && hostItem.objectName === selectedHostForPreview.id) { + hostItem.forceActiveFocus() + return + } + } + if (novaLibraryHosts.length === 0) { + emptyHostState.forceActiveFocus() + return + } + if (novaLibraryGames.length === 0 && emptyGameState.visible) { + emptyGameState.forceActiveFocus() + return + } + if (hostRepeater.itemAt(0) !== null) { + hostRepeater.itemAt(0).forceActiveFocus() + } else { + emptyHostState.forceActiveFocus() + } + } + function activateLaunchPreviewCopyFromController() { const canCopyPreview = launchPreviewCopyAction.enabled && launchPreviewCopyAction.previewText.length > 0 @@ -232,13 +262,13 @@ ApplicationWindow { Keys.onEnterPressed: selectHostForPreview(modelData) Keys.onSpacePressed: selectHostForPreview(modelData) Keys.onDownPressed: { - const next = hostRepeater.itemAt(index + 1) + const next = hostRepeater.itemAt((index + 1) % hostRepeater.count) if (next !== null) { next.forceActiveFocus() } } Keys.onUpPressed: { - const previous = hostRepeater.itemAt(index - 1) + const previous = hostRepeater.itemAt((index + hostRepeater.count - 1) % hostRepeater.count) if (previous !== null) { previous.forceActiveFocus() } @@ -261,6 +291,14 @@ ApplicationWindow { color: "#B8C2F0" font.pixelSize: 16 } + + Label { + visible: selectedHostForPreview.id === modelData.id + text: "Selected host" + color: "#8AFFC1" + font.pixelSize: 12 + font.bold: true + } } } } @@ -281,12 +319,51 @@ ApplicationWindow { Label { Layout.preferredWidth: sampleTextWidth - text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only" : "") + text: novaLibraryFixtureSource + (novaLibraryReadOnly ? " · read-only · Read-only snapshot loaded" : " · Snapshot unavailable in this preview shell — no backend request will be made") color: "#A8B0D8" font.pixelSize: 13 wrapMode: Text.WordWrap } + Rectangle { + id: emptyGameState + objectName: "game-empty-state" + visible: novaLibraryGames.length === 0 + Layout.preferredWidth: sampleCardWidth + Layout.preferredHeight: visible ? 116 : 0 + radius: 18 + color: activeFocus ? "#202B55" : "#151D39" + border.color: activeFocus ? "#B8C2FF" : "#39466F" + border.width: activeFocus ? 5 : 2 + focus: visible + activeFocusOnTab: visible + KeyNavigation.left: novaLibraryHosts.length > 0 ? hostRepeater.itemAt(0) : emptyHostState + KeyNavigation.right: hostDetailPanel + Keys.onLeftPressed: focusSelectedLibraryItem() + Keys.onRightPressed: hostDetailPanel.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 5 + + Label { + text: "No games in read-only snapshot" + color: "#E9ECFF" + font.pixelSize: 20 + font.bold: true + } + + Label { + Layout.preferredWidth: sampleTextWidth + text: "Snapshot unavailable in this preview shell — no backend request will be made." + color: "#A8B0D8" + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + } + Repeater { id: libraryGameRepeater model: novaLibraryGames @@ -314,24 +391,18 @@ ApplicationWindow { Keys.onEnterPressed: selectGameForPreview(modelData) Keys.onSpacePressed: selectGameForPreview(modelData) Keys.onDownPressed: { - const next = libraryGameRepeater.itemAt(index + 1) + const next = libraryGameRepeater.itemAt((index + 1) % libraryGameRepeater.count) if (next !== null) { next.forceActiveFocus() } } Keys.onUpPressed: { - const previous = libraryGameRepeater.itemAt(index - 1) + const previous = libraryGameRepeater.itemAt((index + libraryGameRepeater.count - 1) % libraryGameRepeater.count) if (previous !== null) { previous.forceActiveFocus() } } - Keys.onLeftPressed: { - if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { - hostRepeater.itemAt(0).forceActiveFocus() - } else { - emptyHostState.forceActiveFocus() - } - } + Keys.onLeftPressed: focusSelectedLibraryItem() ColumnLayout { anchors.fill: parent @@ -356,6 +427,14 @@ ApplicationWindow { color: "#A8B0D8" font.pixelSize: 12 } + + Label { + visible: selectedGameForPreview.id === modelData.id + text: "Selected game" + color: "#8AFFC1" + font.pixelSize: 11 + font.bold: true + } } } } @@ -377,14 +456,10 @@ ApplicationWindow { focus: true activeFocusOnTab: true KeyNavigation.left: hostRepeater.itemAt(0) !== null ? hostRepeater.itemAt(0) : emptyHostState + KeyNavigation.up: copyPreviewButton KeyNavigation.down: launchCtaPlaceholder - Keys.onLeftPressed: { - if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { - hostRepeater.itemAt(0).forceActiveFocus() - } else { - emptyHostState.forceActiveFocus() - } - } + Keys.onLeftPressed: focusSelectedLibraryItem() + Keys.onUpPressed: copyPreviewButton.forceActiveFocus() Keys.onDownPressed: launchCtaPlaceholder.forceActiveFocus() ColumnLayout { @@ -448,13 +523,7 @@ ApplicationWindow { Keys.onReturnPressed: activateLaunchPreviewCopyFromController() Keys.onEnterPressed: activateLaunchPreviewCopyFromController() Keys.onSpacePressed: activateLaunchPreviewCopyFromController() - Keys.onLeftPressed: { - if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { - hostRepeater.itemAt(0).forceActiveFocus() - } else { - emptyHostState.forceActiveFocus() - } - } + Keys.onLeftPressed: focusSelectedLibraryItem() ColumnLayout { anchors.fill: parent @@ -518,14 +587,10 @@ ApplicationWindow { focusPolicy: Qt.StrongFocus activeFocusOnTab: true KeyNavigation.up: launchCtaPlaceholder + KeyNavigation.down: hostDetailPanel Keys.onUpPressed: launchCtaPlaceholder.forceActiveFocus() - Keys.onLeftPressed: { - if (novaLibraryHosts.length > 0 && hostRepeater.itemAt(0) !== null) { - hostRepeater.itemAt(0).forceActiveFocus() - } else { - emptyHostState.forceActiveFocus() - } - } + Keys.onDownPressed: hostDetailPanel.forceActiveFocus() + Keys.onLeftPressed: focusSelectedLibraryItem() Keys.onReturnPressed: activateLaunchPreviewCopyFromController() Keys.onEnterPressed: activateLaunchPreviewCopyFromController() Keys.onSpacePressed: activateLaunchPreviewCopyFromController() @@ -535,7 +600,7 @@ ApplicationWindow { Label { id: copyStatusLabel Layout.preferredWidth: detailTextWidth - text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify." + text: launchPreviewCopyAction.idleStatusLabel + " Press A on Copy to verify. A copies the preview URI locally only." 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 b2e644fe..f0ee9bbe 100644 --- a/clients/deck/src/deck_layout.cpp +++ b/clients/deck/src/deck_layout.cpp @@ -12,7 +12,7 @@ constexpr std::string_view kPreviewStateLabel = "Preview only — not executable constexpr std::string_view kPreviewBoundaryId = "deck-launch-preview-only"; constexpr std::string_view kPreviewBoundaryLabel = "Preview-only typed intent boundary"; constexpr std::string_view kPreviewBoundaryReason = "Deck shell may build copyable preview text, but launch execution is blocked."; -constexpr std::string_view kCopyIdleStatusLabel = "Copy action is preview-only and not executable."; +constexpr std::string_view kCopyIdleStatusLabel = "A copies the preview URI locally only — no launch, stream, backend, or Moonlight."; constexpr std::string_view kCopySuccessToast = "Preview text copied for inspection only — still not executable."; constexpr std::string_view kCopyInertToast = "No preview text to copy — preview-only action stayed inert."; @@ -237,6 +237,61 @@ std::vector libraryGameCardsFor(const PolarisGameLibraryFix return cards; } +std::string_view initialLibraryGameFocusTarget(const std::vector& games) { + const auto initial = std::ranges::find_if(games, [](const DeckLibraryGameCard& game) { + return game.initialFocus; + }); + + if (initial != games.end()) { + return initial->id; + } + + if (!games.empty()) { + return games.front().id; + } + + return "game-empty-state"; +} + +std::string_view nextLibraryGameFocusTarget( + const std::vector& games, + const std::string_view currentId, + const DeckFocusDirection direction) { + if (games.empty()) { + return "game-empty-state"; + } + + const auto current = std::ranges::find_if(games, [currentId](const DeckLibraryGameCard& game) { + return game.id == currentId; + }); + + if (current == games.end()) { + return initialLibraryGameFocusTarget(games); + } + + const int verticalDelta = direction == DeckFocusDirection::Up ? -1 : direction == DeckFocusDirection::Down ? 1 : 0; + if (verticalDelta == 0) { + return current->id; + } + + const auto next = std::ranges::find_if(games, [&](const DeckLibraryGameCard& game) { + return game.row == current->row + verticalDelta; + }); + + if (next != games.end()) { + return next->id; + } + + if (direction == DeckFocusDirection::Up) { + return games.back().id; + } + if (direction == DeckFocusDirection::Down) { + return games.front().id; + } + + return current->id; +} + std::string_view initialHostFocusTarget(const std::vector& hosts) { const auto initial = std::ranges::find_if(hosts, [](const DeckHostListItem& host) { return host.initialFocus; @@ -282,6 +337,13 @@ std::string_view nextHostFocusTarget( return next->id; } + if (direction == DeckFocusDirection::Up) { + return hosts.back().id; + } + if (direction == DeckFocusDirection::Down) { + return hosts.front().id; + } + return current->id; } @@ -397,7 +459,7 @@ DeckLaunchPreviewCopyAction copyLaunchPreviewActionFor(const DeckLaunchPreview& const bool hasPreviewText = !preview.text.empty(); return DeckLaunchPreviewCopyAction{ .id = "host-detail-copy-preview", - .label = "Copy preview text", + .label = "Copy preview URI (no launch)", .previewText = preview.text, .idleStatusLabel = hasPreviewText ? std::string(kCopyIdleStatusLabel) : std::string(kCopyInertToast), .successToast = std::string(kCopySuccessToast), @@ -516,6 +578,13 @@ std::string_view nextHostDetailFocusTarget( return next->id; } + if (direction == DeckFocusDirection::Up) { + return targets.back().id; + } + if (direction == DeckFocusDirection::Down) { + return targets.front().id; + } + return current->id; } diff --git a/clients/deck/src/deck_layout.h b/clients/deck/src/deck_layout.h index 079d5e63..6d7727ec 100644 --- a/clients/deck/src/deck_layout.h +++ b/clients/deck/src/deck_layout.h @@ -164,6 +164,13 @@ std::vector libraryHostListStateFor(const PolarisGameLibraryFi std::vector libraryGameCardsFor(const PolarisGameLibraryFixture& library); +std::string_view initialLibraryGameFocusTarget(const std::vector& games); + +std::string_view nextLibraryGameFocusTarget( + const std::vector& games, + std::string_view currentId, + DeckFocusDirection direction); + std::string_view initialHostFocusTarget(const std::vector& hosts); std::string_view nextHostFocusTarget( diff --git a/clients/deck/tests/deck_layout_test.cpp b/clients/deck/tests/deck_layout_test.cpp index c04311fc..7743e893 100644 --- a/clients/deck/tests/deck_layout_test.cpp +++ b/clients/deck/tests/deck_layout_test.cpp @@ -83,6 +83,12 @@ int main() { assert(mainQml.find("selectedGameForPreview") != std::string::npos); assert(mainQml.find("refreshLaunchPreviewBinding") != std::string::npos); assert(mainQml.find("selectedLaunchPreviewText") != std::string::npos); + assert(mainQml.find("Selected host") != std::string::npos); + assert(mainQml.find("Selected game") != std::string::npos); + assert(mainQml.find("No games in read-only snapshot") != std::string::npos); + assert(mainQml.find("Read-only snapshot loaded") != std::string::npos); + assert(mainQml.find("Snapshot unavailable in this preview shell") != std::string::npos); + assert(mainQml.find("A copies the preview URI locally only") != std::string::npos); assert(nova::deck::decodeGamepadAction(nova::deck::DeckGamepadEvent{ .timeMs = 10, @@ -151,6 +157,8 @@ int main() { assert(nova::deck::nextHostFocusTarget(demoHosts, "host-living-room-pc", nova::deck::DeckFocusDirection::Up) == std::string_view("host-gaming-pc")); assert(nova::deck::nextHostFocusTarget(demoHosts, "host-living-room-pc", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-gaming-pc")); + assert(nova::deck::nextHostFocusTarget(demoHosts, "host-gaming-pc", nova::deck::DeckFocusDirection::Up) == std::string_view("host-living-room-pc")); const auto detail = nova::deck::resolveHostDetail(demoHosts, "host-gaming-pc"); @@ -253,9 +261,9 @@ int main() { const auto copyAction = nova::deck::copyLaunchPreviewActionFor(commandPreview); assert(copyAction.id == std::string_view("host-detail-copy-preview")); - assert(copyAction.label == std::string_view("Copy preview text")); + assert(copyAction.label == std::string_view("Copy preview URI (no launch)")); assert(copyAction.previewText == commandPreview.text); - assert(copyAction.idleStatusLabel == "Copy action is preview-only and not executable."); + assert(copyAction.idleStatusLabel == "A copies the preview URI locally only — no launch, stream, backend, or Moonlight."); assert(copyAction.successToast == "Preview text copied for inspection only — still not executable."); assert(copyAction.inertToast == "No preview text to copy — preview-only action stayed inert."); assert(copyAction.copyOnly); @@ -320,7 +328,7 @@ int main() { assert(detailFocus[0].id == std::string_view("host-detail-panel")); assert(detailFocus[1].id == std::string_view("host-detail-launch-cta")); assert(detailFocus[2].id == std::string_view("host-detail-copy-preview")); - assert(detailFocus[2].label == std::string_view("Copy preview text")); + assert(detailFocus[2].label == std::string_view("Copy preview URI (no launch)")); assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-panel", nova::deck::DeckFocusDirection::Down) == std::string_view("host-detail-launch-cta")); assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-launch-cta", nova::deck::DeckFocusDirection::Down) @@ -330,6 +338,8 @@ int main() { assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-launch-cta", nova::deck::DeckFocusDirection::Up) == std::string_view("host-detail-panel")); assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-copy-preview", nova::deck::DeckFocusDirection::Down) + == std::string_view("host-detail-panel")); + assert(nova::deck::nextHostDetailFocusTarget(detailFocus, "host-detail-panel", nova::deck::DeckFocusDirection::Up) == std::string_view("host-detail-copy-preview")); assert(nova::deck::isDeckNativeAspect(1280, 800)); @@ -368,6 +378,16 @@ int main() { assert(libraryCards[1].sourceRuntimeLabel == "Steam · Linux · Proton"); assert(libraryCards[1].launchModeLabel == "Stream: virtual_display · Steam: big-picture"); assert(!libraryCards[1].initialFocus); + assert(nova::deck::initialLibraryGameFocusTarget({}) == std::string_view("game-empty-state")); + assert(nova::deck::initialLibraryGameFocusTarget(libraryCards) == std::string_view("game-123")); + assert(nova::deck::nextLibraryGameFocusTarget(libraryCards, "game-123", nova::deck::DeckFocusDirection::Down) + == std::string_view("game-456")); + assert(nova::deck::nextLibraryGameFocusTarget(libraryCards, "game-456", nova::deck::DeckFocusDirection::Down) + == std::string_view("game-123")); + assert(nova::deck::nextLibraryGameFocusTarget(libraryCards, "game-123", nova::deck::DeckFocusDirection::Up) + == std::string_view("game-456")); + assert(nova::deck::nextLibraryGameFocusTarget({}, "missing-game", nova::deck::DeckFocusDirection::Up) + == std::string_view("game-empty-state")); const auto hadesLaunchCta = nova::deck::inertLaunchCtaFor(detail, library.games[1]); assert(hadesLaunchCta.previewText == std::string_view("preview://nova-deck/launch?host=host-gaming-pc&game=Hades&state=copy-preview-only"));