diff --git a/DESIGN.md b/DESIGN.md index 988fdf3f..a84f2f19 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -964,15 +964,19 @@ the whole session, so without this the app has a live game and a page that looks - **Banner.** Pinned with a top `safeAreaInset` on the page's scroll view, so it stays visible while the page scrolls — the one fact that holds for the whole session, needed most when the user has scrolled down to the rails. A pinned `Section` header would require a `LazyVStack`, which the -home page deliberately avoids. Chrome and control are the active-session banner's -(`VendorActiveSessionBannerButtonStyle`): -`OPNDesign.Surface.chrome`, a 1px Stroke Subtle hairline along the bottom, -`CatalogVendorLayout.sectionHeaderMargin` horizontal padding, a 8pt accent dot, a 10pt bold accent -eyebrow ("STREAM RUNNING", tracking 1.2), then the game title at 14pt bold with the stream's own -status message under it at 11pt Text Secondary. Actions: **FOCUS** (accent fill, the one action the -session banner has no equivalent for) and **END** (neutral fill, 1px Stroke Regular). END routes -through `StreamSessionLifecycle`, so the menu bar, the PiP strip and this button tear down the same -thing. +home page deliberately avoids. That inset's region is the scroll view's *frame*, and a `ScrollView` +is as wide as its widest content — a rail still on its skeleton runs past the window. The page +therefore clamps the scroll view to the page width (`CatalogContentView`): without that clamp the +banner was laid out centred on the inflated width, which left only its right-hand end on screen. +Chrome and control are the active-session banner's, shared through `VendorStatusBannerChrome` and +`VendorActiveSessionBannerButtonStyle`: `OPNDesign.Surface.chrome`, a 1px Stroke Subtle hairline +along the bottom, `CatalogVendorLayout.sectionHeaderMargin` horizontal padding, a 8pt accent dot, a +10pt bold accent eyebrow ("STREAM RUNNING", tracking 1.2), then the game title at 14pt bold. Two +lines, not three: the stream's own status message under the title only restated that it plays in +its own window, and cost the banner a row of height. Actions: **FOCUS** (accent fill, the one +action the session banner has no equivalent for) and **END** (neutral fill, 1px Stroke Regular). +END routes through `StreamSessionLifecycle`, so the menu bar, the PiP strip and this button tear +down the same thing. - **Backdrop.** The running stream's artwork, artwork fill, 18pt blur, then `OPNDesign.Surface.scrim` and the same top/bottom black gradient the store picker uses. Behind the page, never hit-testable, hidden from accessibility. Games page only — Settings and Recordings share the stack and have diff --git a/Tests/Catalog/CatalogRunningStreamBannerLayoutTests.swift b/Tests/Catalog/CatalogRunningStreamBannerLayoutTests.swift new file mode 100644 index 00000000..ce057a88 --- /dev/null +++ b/Tests/Catalog/CatalogRunningStreamBannerLayoutTests.swift @@ -0,0 +1,78 @@ +// The running-stream banner's width: the page clamps its scroll view to the page, because the +// banner's `safeAreaInset` region is that frame and a `ScrollView` is as wide as its content. + +import AppKit +import SwiftUI +import Testing +@testable import OpenNOW + +@MainActor +@Suite struct CatalogRunningStreamBannerLayoutTests { + private static let pageSize = CGSize(width: 900, height: 620) + /// The banner is pinned to the top of the page, so row 4 is banner and the bottom row is bare page. + private static let bannerSampleRow = 4 + private static let pageSampleRowFromBottom = 20 + /// ImageRenderer rasterizes in its own colour space, so rendered pixels are compared to each other. + private static let channelTolerance = 0.01 + + @Test func theBannerReachesBothEdgesOfThePageWhileARailRunsPastIt() throws { + let bitmap = try renderRunningStreamPage() + let leadingEdge = try renderedColor(in: bitmap, x: 0, y: Self.bannerSampleRow) + let trailingEdge = try renderedColor(in: bitmap, x: bitmap.pixelsWide - 1, y: Self.bannerSampleRow) + let pageBackground = try renderedColor(in: bitmap, x: 0, y: bitmap.pixelsHigh - Self.pageSampleRowFromBottom) + + // Both ends of the top band are the banner's chrome ... + #expect( + isSameColor(leadingEdge, trailingEdge), + "the top band is two colours: \(rgbTriplet(of: leadingEdge)) leading, \(rgbTriplet(of: trailingEdge)) trailing" + ) + // ... and it is the banner, not the page showing through where the banner should be. + #expect( + !isSameColor(leadingEdge, pageBackground), + "the banner is missing from the leading edge of the page" + ) + } + + /// ImageRenderer draws the inset even though it skips `ScrollView` content, and the layout that + /// places that inset still runs against the content's width - which is the behaviour under test. + private func renderRunningStreamPage() throws -> NSBitmapImageRep { + let page = CatalogContentView(viewModel: makeRunningStreamViewModel(), isActive: true) + .frame(width: Self.pageSize.width, height: Self.pageSize.height) + let renderer = ImageRenderer(content: page) + renderer.scale = 1 + let image = try #require(renderer.cgImage, "the page did not render") + return NSBitmapImageRep(cgImage: image) + } + + /// A running stream over a page whose only rail is still loading: a plain row of six fixed-width + /// tiles, which is what makes the scroll content wider than the page. + private func makeRunningStreamViewModel() -> CatalogViewModel { + OPNDesign.applyTheme(accent: .cloudGreen, appearance: .dark, systemColorScheme: .dark) + let viewModel = makeCatalogViewModelForTesting() + viewModel.cachedCatalogSections = [ + CatalogSectionModel(id: "loading", title: "Loading", games: [], kind: .catalog, isPlaceholder: true) + ] + viewModel.activeStreamConfiguration = StreamLaunchConfiguration( + title: "The Witcher 3: Wild Hunt - Remastered", + applicationID: "app-1", + accessToken: "token", + accountLinked: true, + selectedStore: "STEAM" + ) + return viewModel + } + + private func renderedColor(in bitmap: NSBitmapImageRep, x: Int, y: Int) throws -> NSColor { + try #require(bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB), "no pixel at \(x),\(y)") + } + + private func isSameColor(_ first: NSColor, _ second: NSColor) -> Bool { + abs(first.redComponent - second.redComponent) < Self.channelTolerance + && abs(first.greenComponent - second.greenComponent) < Self.channelTolerance + && abs(first.blueComponent - second.blueComponent) < Self.channelTolerance + } + + private func rgbTriplet(of color: NSColor) -> String { + "(\(Int(color.redComponent * 255)),\(Int(color.greenComponent * 255)),\(Int(color.blueComponent * 255)))" + } +} diff --git a/View/Catalog/CatalogContentViews.swift b/View/Catalog/CatalogContentViews.swift index 948ccc8b..b84807ee 100644 --- a/View/Catalog/CatalogContentViews.swift +++ b/View/Catalog/CatalogContentViews.swift @@ -37,7 +37,9 @@ struct CatalogContentView: View { let sections = viewModel.catalogSections let isGridDestination = shouldUseGrid(for: viewModel.selectedCatalogDestination) ScrollViewReader { proxy in - ScrollView { + // No indicator, like every other scroll surface here: the page only ever lacked + // one because its frame was as wide as the scroll content. + ScrollView(.vertical, showsIndicators: false) { // Deliberately eager. A LazyVStack here re-runs // `LazyStack.measureEstimates` on every scroll offset change, and // estimating a rail means applying its whole view list - every tile in @@ -58,7 +60,6 @@ struct CatalogContentView: View { VendorActiveSessionHomeBanner( title: viewModel.activeHomeSessionTitle, isResumable: session.isResumable, - serverIp: session.serverIp, availableWidth: viewport.size.width, onResume: { viewModel.resumeActiveHomeSession() }, onEnd: { viewModel.endActiveHomeSession() } @@ -197,6 +198,9 @@ struct CatalogContentView: View { scrollToSelectedRail(selectedRailScrollAnchor, proxy: proxy) } } + // A `ScrollView` is as wide as its widest content, and the running-stream banner's + // `safeAreaInset` region is that frame - so the region is clamped to the page. + .frame(maxWidth: viewport.size.width > 0 ? viewport.size.width : .infinity, alignment: .leading) // Sticky, not scrolled: the banner is the one fact that stays true for the whole // session, and a page scrolled down to the rails is exactly when it is needed. // A `safeAreaInset` keeps it pinned above the content - a pinned `Section` header diff --git a/View/Catalog/CatalogRailViews.swift b/View/Catalog/CatalogRailViews.swift index da843fb4..53852145 100644 --- a/View/Catalog/CatalogRailViews.swift +++ b/View/Catalog/CatalogRailViews.swift @@ -424,52 +424,18 @@ struct CatalogPanelActionTile: View { struct VendorActiveSessionHomeBanner: View { let title: String let isResumable: Bool - let serverIp: String var availableWidth: CGFloat = 0 let onResume: () -> Void let onEnd: () -> Void - @Environment(\.opnUIScale) private var uiScale - var body: some View { - HStack(spacing: 0) { - Circle() - .fill(OPNDesign.accent) - .frame(width: 8 * uiScale, height: 8 * uiScale) - .padding(.trailing, 10 * uiScale) - - VStack(alignment: .leading, spacing: 2 * uiScale) { - Text("SESSION ACTIVE") - .catalogFont(size: 10, weight: .bold) - .foregroundStyle(OPNDesign.accentInk) - .tracking(1.2) - Text(title) - .catalogFont(size: 14, weight: .bold) - .foregroundStyle(OPNDesign.Text.primary) - .lineLimit(1) - } - - Spacer(minLength: 16 * uiScale) - - HStack(spacing: 8 * uiScale) { - if isResumable { - Button("RESUME") { onResume() } - .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true)) - } - Button("END") { onEnd() } - .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false)) + VendorStatusBannerChrome(eyebrow: "SESSION ACTIVE", title: title, availableWidth: availableWidth) { + if isResumable { + Button("RESUME") { onResume() } + .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true)) } - } - .padding(.horizontal, CatalogVendorLayout.sectionHeaderMargin(scale: uiScale)) - .padding(.vertical, 10 * uiScale) - // Clamp before the chrome so the background and hairline paint at the page width, not at - // the scroll view's inflated content width. - .frame(maxWidth: availableWidth > 0 ? availableWidth : .infinity, alignment: .leading) - .background(OPNDesign.Surface.chrome) - .overlay(alignment: .bottom) { - Rectangle() - .fill(OPNDesign.Stroke.subtle) - .frame(height: 1) + Button("END") { onEnd() } + .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false)) } } } diff --git a/View/Catalog/CatalogStatusBannerChrome.swift b/View/Catalog/CatalogStatusBannerChrome.swift new file mode 100644 index 00000000..06ff480b --- /dev/null +++ b/View/Catalog/CatalogStatusBannerChrome.swift @@ -0,0 +1,49 @@ +// The chrome both catalog status banners are drawn on - the running-stream banner and the +// active-session banner - so the surface, metrics and hairline are defined once. + +import SwiftUI + +/// The chrome a catalog status banner sits on: accent dot, eyebrow over title, actions trailing. +struct VendorStatusBannerChrome: View { + let eyebrow: String + let title: String + var availableWidth: CGFloat = 0 + @ViewBuilder let actions: Actions + + @Environment(\.opnUIScale) private var uiScale + + var body: some View { + HStack(spacing: 0) { + Circle() + .fill(OPNDesign.accent) + .frame(width: 8 * uiScale, height: 8 * uiScale) + .padding(.trailing, 10 * uiScale) + + VStack(alignment: .leading, spacing: 2 * uiScale) { + Text(eyebrow) + .catalogFont(size: 10, weight: .bold) + .foregroundStyle(OPNDesign.accentInk) + .tracking(1.2) + Text(title) + .catalogFont(size: 14, weight: .bold) + .foregroundStyle(OPNDesign.Text.primary) + .lineLimit(1) + } + + Spacer(minLength: 16 * uiScale) + + HStack(spacing: 8 * uiScale) { actions } + } + .padding(.horizontal, CatalogVendorLayout.sectionHeaderMargin(scale: uiScale)) + .padding(.vertical, 10 * uiScale) + // Clamp before the chrome so the background and hairline paint at the page width, not at + // the scroll view's inflated content width. + .frame(maxWidth: availableWidth > 0 ? availableWidth : .infinity, alignment: .leading) + .background(OPNDesign.Surface.chrome) + .overlay(alignment: .bottom) { + Rectangle() + .fill(OPNDesign.Stroke.subtle) + .frame(height: 1) + } + } +} diff --git a/View/Catalog/CatalogStreamStatusViews.swift b/View/Catalog/CatalogStreamStatusViews.swift index fb730991..00fda903 100644 --- a/View/Catalog/CatalogStreamStatusViews.swift +++ b/View/Catalog/CatalogStreamStatusViews.swift @@ -15,61 +15,20 @@ import SwiftUI -/// The banner for a stream that is live elsewhere in the app. -/// -/// END routes through `StreamSessionLifecycle`, the same registry the menu bar's End Session and the -/// PiP control strip use, so all three tear down exactly the same thing. +/// The banner for a stream that is live elsewhere in the app. END goes through +/// `StreamSessionLifecycle`, like the menu bar's End Session and the PiP control strip. struct VendorRunningStreamHomeBanner: View { let title: String var availableWidth: CGFloat = 0 let onFocus: () -> Void let onEnd: () -> Void - @Environment(\.opnUIScale) private var uiScale - var body: some View { - HStack(spacing: 0) { - Circle() - .fill(OPNDesign.accent) - .frame(width: 8 * uiScale, height: 8 * uiScale) - .padding(.trailing, 10 * uiScale) - - VStack(alignment: .leading, spacing: 2 * uiScale) { - Text("STREAM RUNNING") - .catalogFont(size: 10, weight: .bold) - .foregroundStyle(OPNDesign.accentInk) - .tracking(1.2) - Text(title) - .catalogFont(size: 14, weight: .bold) - .foregroundStyle(OPNDesign.Text.primary) - .lineLimit(1) - // Deliberately constant rather than the last progress message: that is whatever the - // launch said on its way past ("Starting GeForce NOW stream..."), and a running - // stream showing that reads as a stall. What is true for the whole session is where - // the picture is. - Text("Playing in its own window") - .catalogFont(size: 11) - .foregroundStyle(OPNDesign.Text.secondary) - .lineLimit(1) - } - - Spacer(minLength: 16 * uiScale) - - HStack(spacing: 8 * uiScale) { - Button("FOCUS") { onFocus() } - .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true)) - Button("END") { onEnd() } - .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false)) - } - } - .padding(.horizontal, CatalogVendorLayout.sectionHeaderMargin(scale: uiScale)) - .padding(.vertical, 10 * uiScale) - .frame(maxWidth: availableWidth > 0 ? availableWidth : .infinity, alignment: .leading) - .background(OPNDesign.Surface.chrome) - .overlay(alignment: .bottom) { - Rectangle() - .fill(OPNDesign.Stroke.subtle) - .frame(height: 1) + VendorStatusBannerChrome(eyebrow: "STREAM RUNNING", title: title, availableWidth: availableWidth) { + Button("FOCUS") { onFocus() } + .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: true)) + Button("END") { onEnd() } + .buttonStyle(VendorActiveSessionBannerButtonStyle(primary: false)) } } } diff --git a/View/Catalog/ControllerCatalogView.swift b/View/Catalog/ControllerCatalogView.swift index 1148b15d..dea68721 100644 --- a/View/Catalog/ControllerCatalogView.swift +++ b/View/Catalog/ControllerCatalogView.swift @@ -518,7 +518,6 @@ private struct ControllerGamesPage: View { VendorActiveSessionHomeBanner( title: viewModel.activeHomeSessionTitle, isResumable: session.isResumable, - serverIp: session.serverIp, availableWidth: layout.contentWidth, onResume: { viewModel.resumeActiveHomeSession() }, onEnd: { viewModel.endActiveHomeSession() }