From 38b9f310e29b683240d6170298b4b48c76214f68 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 10 Jul 2026 01:42:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(clients):=20tiered=20stats=20overlay=20eve?= =?UTF-8?q?rywhere=20=E2=80=94=20Compact/Normal/Detailed=20on=20every=20pl?= =?UTF-8?q?atform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the Android client's 3-tier stats-overlay semantics in every other client (design/stats-unification.md vocabulary): Off → Compact (one line: fps · e2e ms · Mb/s + loss flag) → Normal (mode + e2e p50/p95 + loss counters) → Detailed (decoder path, HDR tag, per-stage latency equation). Apple: new StatsVerbosity in PunktfunkKit persisted under punktfunk.statsVerbosity (migrates the legacy hudEnabled bool: explicit off → Off, else Normal). The existing three-finger tap (TouchMouse, trackpad/pointer modes only — touch passthrough untouched) now cycles the tiers instead of toggling, matching Android; ⌃⌥⇧S (menu + captured-state monitor) cycles the same ladder. Tiered StreamHUDView (compact glass pill / headline HUD / full equation HUD); the iOS corner disconnect also shows in Compact (the pill carries no button). Tier pickers on iOS, macOS, tvOS and the gamepad settings UI. Session stack (Linux + Windows + Deck share punktfunk-session): shared pf_client_core::trust::StatsVerbosity; Settings grows stats_verbosity with a show_stats fallback, and writes keep the legacy bool in sync so pre-tier binaries reading the same JSON agree on off vs on. Ctrl+Alt+Shift+S cycles the tier and re-renders the OSD immediately from the last stats window; the stdout stats: line always carries the full Detailed text so the shell status card and scripts keep a stable shape; --stats bumps Off → Normal without demoting a richer tier. Tier pickers in the GTK dialog, the WinUI settings page and the console-UI settings row; shortcut copy updated (GTK shortcuts window, Windows help, session README). The Windows legacy builtin path keeps its bool HUD. Tests: tier migration/round-trip in trust.rs, tiered stats_text output in pf-presenter. Co-Authored-By: Claude Fable 5 --- .../Sources/PunktfunkClient/ContentView.swift | 28 ++- .../Session/StreamCommands.swift | 15 +- .../Session/StreamHUDView.swift | 74 ++++++- .../Settings/GamepadSettingsView.swift | 13 +- .../Settings/SettingsOptions.swift | 4 + .../Settings/SettingsView+Sections.swift | 8 +- .../Settings/SettingsView+Support.swift | 12 +- .../Settings/SettingsView.swift | 10 +- .../PunktfunkKit/Input/InputCapture.swift | 8 +- .../PunktfunkKit/Input/TouchMouse.swift | 16 +- .../PunktfunkKit/Support/DefaultsKeys.swift | 10 +- .../PunktfunkKit/Support/StatsVerbosity.swift | 58 ++++++ .../PunktfunkKit/Views/StreamView.swift | 11 +- clients/linux/src/app.rs | 2 +- clients/linux/src/ui_settings.rs | 24 ++- clients/session/README.md | 7 +- clients/session/src/console.rs | 6 +- clients/session/src/main.rs | 7 +- clients/windows/src/app/help.rs | 5 +- clients/windows/src/app/settings.rs | 26 ++- crates/pf-client-core/src/trust.rs | 94 ++++++++- crates/pf-console-ui/src/screens/settings.rs | 16 +- crates/pf-presenter/src/run.rs | 193 +++++++++++++++--- 23 files changed, 528 insertions(+), 119 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 1e6f36e9..494b511b 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -31,8 +31,15 @@ struct ContentView: View { @AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true - @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true + // The raw string is what @AppStorage observes (so cycles from any surface re-render this + // view); the absent-key default runs the legacy-hudEnabled migration once per init. + @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw + = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue + /// The persisted overlay tier (unknown raw falls back to .normal, like the migration). + private var statsVerbosity: StatsVerbosity { + StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal + } /// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto). private var preferredCodecByte: UInt8 { switch codec { @@ -393,8 +400,10 @@ struct ContentView: View { displayMeter: model.displayStage ) .overlay(alignment: placement.alignment) { - if captureEnabled && hudEnabled { - StreamHUDView(model: model, connection: conn, placement: placement) + if captureEnabled && statsVerbosity != .off { + StreamHUDView( + model: model, connection: conn, placement: placement, + verbosity: statsVerbosity) } } #if os(macOS) @@ -422,17 +431,18 @@ struct ContentView: View { } #endif #if os(iOS) - // Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button) - // is hidden, keep a minimal always-reachable exit in a corner. It rides a - // material disc (like the HUD) so the glyph stays legible over a bright frame - // — this is the sole touch disconnect path when stats are off. + // Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on + // screen — the overlay off, or the compact pill (which carries no button) — + // keep a minimal always-reachable exit in a corner. It rides a material disc + // (like the HUD) so the glyph stays legible over a bright frame — this is the + // sole touch disconnect path in those tiers. .overlay(alignment: .topLeading) { - if captureEnabled && !hudEnabled { + if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) { Button { model.disconnect() } label: { Image(systemName: "xmark") .font(.headline.weight(.semibold)) .frame(width: 36, height: 36) - // Sole touch exit when the HUD is off — a floating glass disc + // Sole touch exit in the off/compact tiers — a floating glass disc // over the frame (26+, material fallback). interactive: the disc // IS the tap target, so the glass reacts to press. .glassBackground(Circle(), interactive: true) diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index 013b2263..18a41660 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -5,8 +5,9 @@ // (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While // input is CAPTURED these key equivalents never reach the menu (the stream view swallows // keys); InputCapture's monitor detects the same combos there and performs the same actions — -// the menu covers the released state and discoverability. The stats toggle just flips the -// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts. +// the menu covers the released state and discoverability. The stats item cycles the shared +// `statsVerbosity` tier (off → compact → normal → detailed → off); ContentView reads the same +// @AppStorage and reacts. // // tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri // Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is @@ -36,12 +37,16 @@ extension FocusedValues { struct StreamCommands: Commands { @FocusedValue(\.sessionFocus) private var session - @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true + // The raw string so @AppStorage observes the shared key; the absent-key default runs the + // legacy-hudEnabled migration (same pattern as ContentView/SettingsView). + @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw + = StatsVerbosity.current.rawValue var body: some Commands { CommandMenu("Stream") { - Button(hudEnabled ? "Hide Statistics" : "Show Statistics") { - hudEnabled.toggle() + Button("Cycle Statistics") { + let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal + statsVerbosityRaw = current.next().rawValue } .keyboardShortcut("s", modifiers: [.control, .option, .shift]) // Reaches the key window's stream view via NotificationCenter — capture is view diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index e4793da0..cdaf5cff 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -1,7 +1,10 @@ -// The streaming overlay HUD: mode + fps/throughput, the unified latency lines -// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the -// capture→received headline under the stage-1 fallback), the loss counter, the platform input -// hint, and disconnect. +// The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics): +// * compact — one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy); +// * normal — mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md +// — end-to-end under stage-2, capture→received under the stage-1 fallback), the loss +// counter, the platform input hint, and disconnect; +// * detailed — everything normal has plus the stage equation line(s) under the headline. +// `.off` never reaches this view (ContentView gates the overlay on the tier). import PunktfunkKit import SwiftUI @@ -10,8 +13,56 @@ struct StreamHUDView: View { @ObservedObject var model: SessionModel let connection: PunktfunkConnection var placement: HUDPlacement = .topTrailing + let verbosity: StatsVerbosity var body: some View { + // .off is gated upstream (ContentView only mounts the HUD when the tier is on) — + // render nothing if it ever slips through. + if verbosity == .compact { + compactPill + } else if verbosity != .off { + fullHUD + } + } + + // MARK: - Compact tier + + /// One line on the glass pill: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is + /// the best available latency headline (stage-2 end-to-end, else the stage-1 + /// capture→received) and is omitted until either is valid. Loss appends in the same quiet + /// styling the full HUD's lost line uses. + private var compactPill: some View { + HStack(spacing: 6) { + Circle() + .fill(Color.accentColor) + .frame(width: 7, height: 7) + Text(compactLine) + .font(.system(.caption, design: .monospaced)) + if model.lostFrames > 0 { + Text("· lost \(model.lostFrames)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(10) + .glassBackground(RoundedRectangle(cornerRadius: 10)) + .padding(10) + } + + private var compactLine: String { + var parts = ["\(model.fps) fps"] + if model.endToEndValid { + parts.append(String(format: "%.1f ms", model.endToEndP50Ms)) + } else if model.hostNetworkValid { + parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms)) + } + parts.append(String(format: "%.1f Mb/s", model.mbps)) + return parts.joined(separator: " · ") + } + + // MARK: - Normal / detailed tiers + + private var fullHUD: some View { VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) { HStack(spacing: 6) { Circle() @@ -26,11 +77,11 @@ struct StreamHUDView: View { Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - // The equation: the stages tiling the headline interval (per-window p50s — - // they only approximately sum to the directly-measured total). With a host - // that reports per-AU timings (0xCF) the first term splits into host + network - // (phase 2); an old host keeps the combined term. - if model.hostNetworkValid && model.decodeValid && model.displayValid { + // The equation (detailed tier only): the stages tiling the headline interval + // (per-window p50s — they only approximately sum to the directly-measured + // total). With a host that reports per-AU timings (0xCF) the first term splits + // into host + network (phase 2); an old host keeps the combined term. + if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid { if model.splitValid { Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")") .font(.system(.caption2, design: .monospaced)) @@ -45,11 +96,12 @@ struct StreamHUDView: View { // Stage-1 fallback presenter: the layer decodes + presents internally with no // per-frame stamp, so the honest headline ends at receipt. The host/network // split still applies there (receipt is presenter-independent) — it becomes the - // only equation line; without it, host+network IS the whole measured interval. + // only equation line (detailed tier); without it, host+network IS the whole + // measured interval. Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - if model.splitValid { + if verbosity == .detailed && model.splitValid { Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 0eb64691..30fc6066 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -29,7 +29,10 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.enable444) private var enable444 = true @AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true - @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true + // The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the + // legacy-hudEnabled migration (same pattern as ContentView/SettingsView). + @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw + = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @@ -271,10 +274,12 @@ struct GamepadSettingsView: View { options: SettingsOptions.padTypes, current: gamepadType ) { gamepadType = $0 }, - toggleRow( + choiceRow( id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", - detail: "Resolution, frame rate, throughput and latency while streaming.", - value: $hudEnabled), + detail: "How much to show while streaming — Compact is a one-line pill, " + + "Detailed adds the latency stage breakdown.", + options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw + ) { statsVerbosityRaw = $0 }, choiceRow( id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", detail: "Which corner the statistics overlay sits in.", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index b6618c89..c51afe67 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -37,6 +37,10 @@ enum SettingsOptions { static let hudPlacements: [(label: String, tag: String)] = HUDPlacement.allCases.map { ($0.label, $0.rawValue) } + /// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value. + static let statsVerbosities: [(label: String, tag: String)] = + StatsVerbosity.allCases.map { ($0.label, $0.rawValue) } + /// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from. /// AV1 appears only on devices with an AV1 hardware decoder (the same /// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 21b50d53..494f1587 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -385,13 +385,17 @@ extension SettingsView { @ViewBuilder var statisticsSection: some View { Section { - Toggle("Show statistics overlay", isOn: $hudEnabled) + Picker("Statistics overlay", selection: $statsVerbosityRaw) { + ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in + Text(tier.label).tag(tier.rawValue) + } + } Picker("Position", selection: $hudPlacement) { ForEach(HUDPlacement.allCases) { placement in Text(placement.label).tag(placement.rawValue) } } - .disabled(!hudEnabled) + .disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue) } header: { Text("Statistics") } footer: { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift index be9f3d23..b8b0c57c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift @@ -54,10 +54,14 @@ extension SettingsView { // MARK: - Statistics static var statisticsFooter: String { - let base = "Shows resolution, frame rate, throughput and latency in the chosen " - + "corner while streaming." - #if os(macOS) || os(iOS) - return base + " Toggle it any time with ⌃⌥⇧S." + let base = "Shows streaming statistics in the chosen corner — Compact is a one-line " + + "pill, Normal adds resolution and latency, Detailed adds the latency stage " + + "breakdown." + #if os(macOS) + return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time." + #elseif os(iOS) + return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed " + + "any time." #else return base #endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 28796b01..512c7ca8 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -38,7 +38,9 @@ struct SettingsView: View { @AppStorage(DefaultsKey.micEnabled) var micEnabled = true @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @AppStorage(DefaultsKey.codec) var codec = "auto" - @AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true + // The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs + // the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands). + @AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @ObservedObject var gamepads = GamepadManager.shared #if !os(tvOS) @@ -294,10 +296,6 @@ struct SettingsView: View { }) } - private var hudEnabledTag: Binding { - Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" }) - } - private var hdrEnabledTag: Binding { Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) } @@ -352,7 +350,7 @@ struct SettingsView: View { .padding(.top, 8) TVSelectionRow( title: "Statistics overlay", - options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag) + options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw) TVSelectionRow( title: "Statistics position", options: SettingsOptions.hudPlacements, selection: $hudPlacement) diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index e1e9fd57..182625e7 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -113,11 +113,11 @@ public final class InputCapture { /// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which /// carries the same key equivalents for discoverability) can't see them, so the monitor is the /// captured-state delivery path; released, the events pass through and the menu handles them. - /// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats - /// overlay. Main queue. + /// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats + /// overlay tier (off → compact → normal → detailed). Main queue. public var onReleaseCapture: (() -> Void)? public var onDisconnect: (() -> Void)? - public var onToggleStats: (() -> Void)? + public var onCycleStats: (() -> Void)? /// Fired when a newer InputCapture takes the process-global GC handler slots (the /// singletons hold ONE handler each): the preempted owner must drop its capture @@ -246,7 +246,7 @@ public final class InputCapture { return nil case 1 /* S */: self.suppressedVK = 0x53 - self.onToggleStats?() + self.onCycleStats?() return nil default: break diff --git a/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift b/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift index bffc4953..d6489853 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/TouchMouse.swift @@ -2,7 +2,8 @@ // touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel // identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger // tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag -// (text selection / window moves) · three-finger tap = stats-HUD toggle: +// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers +// (off → compact → normal → detailed, matching Android): // // * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's // relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it @@ -164,7 +165,7 @@ final class TouchMouse { } else if !moved { switch maxFingers { case 3...: - Self.toggleHUD() // in-stream stats-overlay toggle, same as Android + Self.cycleStats() // in-stream stats-tier cycle, same as Android case 2: // two-finger tap → right click send?(.mouseButton(Button.right, down: true)) send?(.mouseButton(Button.right, down: false)) @@ -274,12 +275,11 @@ final class TouchMouse { } } - /// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default, - /// which the app's HUD views observe via @AppStorage (so this needs no wiring to them). - private static func toggleHUD() { - let defaults = UserDefaults.standard - let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true - defaults.set(!on, forKey: DefaultsKey.hudEnabled) + /// Three-finger tap cycles the stats overlay tiers (off → compact → normal → detailed) — + /// through the shared `statsVerbosity` default, which the app's HUD views observe via + /// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap. + private static func cycleStats() { + StatsVerbosity.store(StatsVerbosity.current.next()) } } #endif diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index b66186d2..d1e14b7d 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -67,9 +67,15 @@ public enum DefaultsKey { public static let libraryEnabled = "punktfunk.libraryEnabled" /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming" - /// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle - /// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard). + /// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the + /// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never + /// written (absent-or-true → .normal, explicit false → .off). Never written anymore. public static let hudEnabled = "punktfunk.hudEnabled" + /// The statistics overlay tier — a `StatsVerbosity` raw value ("off"/"compact"/"normal"/ + /// "detailed"). Absent → migrated from the legacy `hudEnabled` bool (see above). Cycle it + /// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware + /// keyboard) or a three-finger tap (touch), matching the Android client. + public static let statsVerbosity = "punktfunk.statsVerbosity" /// Which corner the statistics overlay sits in — a `HUDPlacement` raw value /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. public static let hudPlacement = "punktfunk.hudPlacement" diff --git a/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift b/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift new file mode 100644 index 00000000..0bcf90cf --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Support/StatsVerbosity.swift @@ -0,0 +1,58 @@ +// The stats overlay's verbosity tier — a port of the Android client's 3-tier overlay semantics +// so the two clients feel identical: Off → Compact (one-line pill) → Normal (headline stats) → +// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`; +// the in-stream cycle surfaces (⌃⌥⇧S, the three-finger tap) advance it with +// `store(current.next())`, and every UI reader observes the same default via @AppStorage. +// +// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger +// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly. + +import Foundation + +/// How much of the streaming statistics overlay to show. The raw values are stable on disk — +/// rename the cases freely, never the strings. +public enum StatsVerbosity: String, CaseIterable, Sendable { + case off, compact, normal, detailed + + /// User-facing tier label (Settings pickers, the gamepad settings row). + public var label: String { + switch self { + case .off: return "Off" + case .compact: return "Compact" + case .normal: return "Normal" + case .detailed: return "Detailed" + } + } + + /// The next tier in the cycle: off → compact → normal → detailed → off (wrapping) — + /// the ⌃⌥⇧S / three-finger-tap order, same as Android. + public func next() -> StatsVerbosity { + switch self { + case .off: return .compact + case .compact: return .normal + case .normal: return .detailed + case .detailed: return .off + } + } + + /// The persisted tier. When `statsVerbosity` has never been written, migrates from the + /// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true → `.normal` + /// (the old "on" look minus the equation lines), explicit false → `.off`. + public static var current: StatsVerbosity { + let defaults = UserDefaults.standard + if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity), + let tier = StatsVerbosity(rawValue: raw) { + return tier + } + if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy { + return .off + } + return .normal + } + + /// Persist a tier (the cycle surfaces write through here; the Settings pickers write the + /// same key via @AppStorage). + public static func store(_ tier: StatsVerbosity) { + UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index d245a943..9dede3c3 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -594,13 +594,12 @@ public final class StreamLayerView: NSView { guard let self, self.window?.isKeyWindow == true else { return } self.onDisconnectRequest?() } - capture.onToggleStats = { [weak self] in + capture.onCycleStats = { [weak self] in guard self?.window?.isKeyWindow == true else { return } - // Flip the shared setting directly — every @AppStorage reader (the HUD's visibility, - // the menu item's title) observes UserDefaults, so this is the same as the menu path. - let defaults = UserDefaults.standard - let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true - defaults.set(!current, forKey: DefaultsKey.hudEnabled) + // Advance the shared tier setting directly — every @AppStorage reader (the HUD's + // visibility/content, the Settings pickers) observes UserDefaults, so this is the + // same as the menu path. + StatsVerbosity.store(StatsVerbosity.current.next()) } capture.start() inputCapture = capture diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 3b81fa4a..3c053750 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -670,7 +670,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow - Toggle statistics overlay + Cycle the statistics overlay (off · compact · normal · detailed) <Control><Alt><Shift>s diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index bed12972..a2d18ba2 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -3,6 +3,7 @@ use crate::trust::Settings; use adw::prelude::*; +use pf_client_core::trust::StatsVerbosity; use std::cell::{Cell, RefCell}; use std::rc::Rc; @@ -324,10 +325,13 @@ pub fn show( "Software", ], ); - let stats_row = adw::SwitchRow::builder() - .title("Show statistics overlay") - .subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") - .build(); + let stats_row = ChoiceRow::new( + &dialog, + inline, + "Statistics overlay", + "Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live", + &["Off", "Compact", "Normal", "Detailed"], + ); let fullscreen_row = adw::SwitchRow::builder() .title("Start streams in fullscreen") .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") @@ -338,7 +342,7 @@ pub fn show( stream.add(compositor_row.widget()); stream.add(decoder_row.widget()); stream.add(&fullscreen_row); - stream.add(&stats_row); + stream.add(stats_row.widget()); let input = adw::PreferencesGroup::builder().title("Input").build(); // Which physical controller forwards as pad 0: automatic = the most recently connected @@ -483,7 +487,11 @@ pub fn show( compositor_row.set_selected(comp_i as u32); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); decoder_row.set_selected(dec_i as u32); - stats_row.set_active(s.show_stats); + let stats_i = StatsVerbosity::ALL + .iter() + .position(|v| *v == s.stats_verbosity()) + .unwrap_or(0); + stats_row.set_selected(stats_i as u32); fullscreen_row.set_active(s.fullscreen_on_stream); inhibit_row.set_active(s.inhibit_shortcuts); mic_row.set_active(s.mic_enabled); @@ -509,7 +517,9 @@ pub fn show( s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] .to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); - s.show_stats = stats_row.is_active(); + s.set_stats_verbosity( + StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)], + ); s.fullscreen_on_stream = fullscreen_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active(); s.mic_enabled = mic_row.is_active(); diff --git a/clients/session/README.md b/clients/session/README.md index dae1e349..06d0c10f 100644 --- a/clients/session/README.md +++ b/clients/session/README.md @@ -21,7 +21,8 @@ Reads the same identity / known-hosts / settings stores as the desktop client connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). Stdout is the machine interface: `{"ready":true}` after the first presented frame, -`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), one +`stats: …` once per second while the overlay tier isn't Off (always the full detailed +text, whatever the OSD shows; `--stats` forces the overlay on), one `{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0` clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter init failed. @@ -31,7 +32,9 @@ releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller chord (L1+R1+Start+Select, hold to disconnect) works the same. The default build carries the Skia console UI (`ui` feature): the stats OSD and capture -hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror). +hint render in-window. Ctrl+Alt+Shift+S cycles the OSD tier live — Off → Compact (one +line: fps · latency · Mb/s) → Normal (mode + end-to-end percentiles) → Detailed (decoder +path + per-stage latency equation); any tier but Off also emits the stdout mirror. `--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout only, no Skia anywhere in the dependency tree. diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index fcd7d07a..4118286d 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -135,7 +135,11 @@ pub fn run(target: Option<&str>) -> u8 { ), fullscreen: fullscreen_mode(), window_pos: window_pos(), - print_stats: settings_at_start.show_stats || arg_flag("--stats"), + // `--stats` forces the overlay visible without demoting a richer chosen tier. + stats_verbosity: match settings_at_start.stats_verbosity() { + trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal, + v => v, + }, json_status, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { trust::touch_last_used(&trust::hex(&fingerprint)); diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 58764b13..f8cfe914 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -322,7 +322,12 @@ mod session_main { window_title: format!("Punktfunk · {title}"), fullscreen, window_pos: window_pos(), - print_stats: settings.show_stats || arg_flag("--stats"), + // `--stats` forces the overlay visible (tooling/debug runs) without + // demoting an explicitly chosen richer tier. + stats_verbosity: match settings.stats_verbosity() { + trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal, + v => v, + }, json_status: true, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { // This host's card carries the accent bar in the desktop client now. diff --git a/clients/windows/src/app/help.rs b/clients/windows/src/app/help.rs index 949ea8c5..372fcb57 100644 --- a/clients/windows/src/app/help.rs +++ b/clients/windows/src/app/help.rs @@ -19,7 +19,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[ "Release captured input (click the stream to recapture)", ), ("Ctrl+Alt+Shift+D", "Disconnect"), - ("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), + ( + "Ctrl+Alt+Shift+S", + "Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)", + ), ( "LB+RB+Start+Back", "Controller: release input / leave fullscreen \u{2014} hold to disconnect", diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 8c235108..11e131a7 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -4,6 +4,7 @@ use super::style::*; use super::{AppCtx, Screen}; use crate::trust::Settings; +use pf_client_core::trust::StatsVerbosity; use punktfunk_core::config::GamepadPref; use std::sync::Arc; use windows_reactor::*; @@ -46,6 +47,14 @@ const GAMEPADS: &[(&str, &str)] = &[ ("xboxone", "Xbox One"), ("dualshock4", "DualShock 4"), ]; +/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder +/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window. +const STATS_TIERS: &[(StatsVerbosity, &str)] = &[ + (StatsVerbosity::Off, "Off"), + (StatsVerbosity::Compact, "Compact"), + (StatsVerbosity::Normal, "Normal"), + (StatsVerbosity::Detailed, "Detailed"), +]; /// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to /// auto-detect when the choice is unavailable. Only meaningful against a Linux host. const COMPOSITORS: &[(&str, &str)] = &[ @@ -328,15 +337,14 @@ pub(crate) fn settings_page( ) .tooltip("Sends the default microphone to the host's virtual mic source."); - let hud_toggle = setting_toggle( - ctx, - "Show the stats overlay (HUD)", - s.show_stats, - |s, on| s.show_stats = on, - ) + let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity()); + let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| { + s.set_stats_verbosity(STATS_TIERS[i].0); + }) .tooltip( - "The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \ - Ctrl+Alt+Shift+S toggles it live while streaming.", + "How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \ + in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \ + Ctrl+Alt+Shift+S cycles the tiers live while streaming.", ); let licenses_button = { @@ -368,7 +376,7 @@ pub(crate) fn settings_page( codec_combo.into(), bitrate_box.into(), hdr_toggle.into(), - hud_toggle.into(), + hud_combo.into(), ]); controls }), diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index f4544e6f..ec4a12bc 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -254,6 +254,50 @@ pub fn probe_reachable_many( .collect() } +/// How much the on-stream statistics overlay shows — the Android client's tiers, shared +/// across every client (design/stats-unification.md): each tier is a strict superset of +/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StatsVerbosity { + Off, + /// One glanceable line: fps · end-to-end ms · Mb/s. + Compact, + /// Stream mode plus the end-to-end latency percentiles and loss counters. + Normal, + /// Everything: decoder path, HDR tags, and the per-stage latency equation. + Detailed, +} + +impl StatsVerbosity { + /// Cycle order (also the settings pickers' option order). + pub const ALL: [StatsVerbosity; 4] = [ + StatsVerbosity::Off, + StatsVerbosity::Compact, + StatsVerbosity::Normal, + StatsVerbosity::Detailed, + ]; + + /// The next tier in the live cycle, wrapping back to Off. + pub fn next(self) -> StatsVerbosity { + match self { + StatsVerbosity::Off => StatsVerbosity::Compact, + StatsVerbosity::Compact => StatsVerbosity::Normal, + StatsVerbosity::Normal => StatsVerbosity::Detailed, + StatsVerbosity::Detailed => StatsVerbosity::Off, + } + } + + pub fn label(self) -> &'static str { + match self { + StatsVerbosity::Off => "Off", + StatsVerbosity::Compact => "Compact", + StatsVerbosity::Normal => "Normal", + StatsVerbosity::Detailed => "Detailed", + } + } +} + /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] @@ -301,10 +345,16 @@ pub struct Settings { /// `default = true`: the Linux stores never carried this and always advertised. #[serde(default = "default_true")] pub hdr_enabled: bool, - /// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). - /// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`. + /// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept + /// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same + /// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted + /// this as `show_hud`. #[serde(alias = "show_hud")] pub show_stats: bool, + /// Stats overlay tier. `None` = a pre-tier store; resolve through + /// [`Settings::stats_verbosity`], which falls back to `show_stats`. + #[serde(skip_serializing_if = "Option::is_none")] + pub stats_verbosity: Option, /// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge /// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless. pub fullscreen_on_stream: bool, @@ -322,6 +372,23 @@ fn default_true() -> bool { } impl Settings { + /// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false` + /// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed). + pub fn stats_verbosity(&self) -> StatsVerbosity { + self.stats_verbosity.unwrap_or(if self.show_stats { + StatsVerbosity::Normal + } else { + StatsVerbosity::Off + }) + } + + /// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier + /// binaries that read the same settings file. + pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) { + self.stats_verbosity = Some(v); + self.show_stats = v != StatsVerbosity::Off; + } + /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). pub fn preferred_codec(&self) -> u8 { match self.codec.as_str() { @@ -351,6 +418,7 @@ impl Default for Settings { adapter: String::new(), hdr_enabled: true, show_stats: true, + stats_verbosity: None, fullscreen_on_stream: true, library_enabled: false, } @@ -432,6 +500,28 @@ mod tests { assert!(!s.library_enabled); } + /// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off, + /// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy + /// bool in sync so pre-tier binaries reading the same file agree on off vs on. + #[test] + fn stats_verbosity_migrates_and_round_trips() { + let mut s: Settings = serde_json::from_str("{}").unwrap(); + assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal); + let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap(); + assert_eq!(off.stats_verbosity(), StatsVerbosity::Off); + + s.set_stats_verbosity(StatsVerbosity::Compact); + assert!(s.show_stats); + s.set_stats_verbosity(StatsVerbosity::Off); + assert!(!s.show_stats); + + s.set_stats_verbosity(StatsVerbosity::Detailed); + let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed); + // The tier serializes lowercase — the file stays human-readable. + assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\"")); + } + /// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same /// filename, same directory, so on Windows the two clients genuinely share the store. #[test] diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 9e4aef91..ca7b9865 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -10,6 +10,7 @@ use crate::screens::{Ctx, Outbox}; use crate::theme::{Fonts, DIM, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; +use pf_client_core::trust::StatsVerbosity; use skia_safe::{Canvas, Rect}; /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale @@ -241,7 +242,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { RowId::Stats => ( Some("Interface"), "Statistics overlay", - on_off(s.show_stats).into(), + s.stats_verbosity().label().into(), ), }; RowSpec { @@ -274,7 +275,10 @@ fn detail(id: RowId) -> &'static str { RowId::Mic => "Send this device's microphone to the host's virtual mic.", RowId::Pad => "Which pad is forwarded to the host, as player 1.", RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.", - RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.", + RowId::Stats => { + "How much the overlay shows: Compact (one line) → Normal → Detailed. \ + Ctrl+Alt+Shift+S cycles it live while streaming." + } } } @@ -332,7 +336,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone()) } RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap), - RowId::Stats => toggle(&mut s.show_stats, delta, wrap), + RowId::Stats => { + let cur = StatsVerbosity::ALL + .iter() + .position(|v| *v == s.stats_verbosity()); + step_option(cur, StatsVerbosity::ALL.len(), delta, wrap) + .map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i])) + } } .is_some() } diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index df44e174..c0f7dcec 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -8,8 +8,10 @@ //! library; the app quits only on B/window-close). //! //! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}` -//! line after the first presented frame, `stats: …` lines once per window while enabled -//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so). +//! line after the first presented frame, `stats: …` lines once per window while the +//! overlay tier isn't Off (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed; +//! the stdout line always carries the full Detailed text so parsers see a stable +//! shape). Logs go to stderr (the binary configures tracing so). use crate::input::Capture; use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase}; @@ -17,6 +19,7 @@ use crate::vk::{FrameInput, Presenter}; use anyhow::{Context as _, Result}; use pf_client_core::gamepad::GamepadService; use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; +use pf_client_core::trust::StatsVerbosity; use pf_client_core::video::VulkanDecodeDevice; use pf_client_core::video::{DecodedFrame, DecodedImage}; use punktfunk_core::client::NativeClient; @@ -37,8 +40,9 @@ pub struct SessionOpts { /// changing content, not a window jumping displays). Fullscreen follows the display /// this lands on. pub window_pos: Option<(i32, i32)>, - /// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live). - pub print_stats: bool, + /// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines + /// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live). + pub stats_verbosity: StatsVerbosity, /// Emit the `{"ready":true}` stdout line after the first presented frame. pub json_status: bool, /// Called once on `Connected` with the host's fingerprint (trust persistence is the @@ -162,8 +166,11 @@ struct StreamState { // all) demotes the decoder to software via the shared flag — once per session. dmabuf_demoted: bool, hw_fails: u32, - /// The OSD's text (multi-line; rebuilt each Stats window). + /// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle). osd_text: String, + /// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the + /// OSD immediately instead of waiting up to 1 s for the next Stats event. + last_stats: Option, } impl StreamState { @@ -207,6 +214,7 @@ impl StreamState { dmabuf_demoted: false, hw_fails: 0, osd_text: String::new(), + last_stats: None, } } @@ -351,7 +359,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let mouse = sdl.mouse(); let mut fullscreen = opts.fullscreen; - let mut print_stats = opts.print_stats; + let mut stats_verbosity = opts.stats_verbosity; let mut overlay_frame: Option = None; // SDL text input tracks the overlay's editing state (started = IME/`TextInput` // events on desktop, and the door Steam's on-screen keyboard types through under @@ -452,7 +460,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result continue; } if chord && sc == Scancode::S { - print_stats = !print_stats; + stats_verbosity = stats_verbosity.next(); + tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity"); + // Re-render the OSD from the last window immediately — waiting + // for the next Stats event would lag the keypress by up to 1 s. + if let Some(st) = &mut stream { + let text = match &st.last_stats { + Some(s) => stats_text( + stats_verbosity, + &st.mode_line, + s, + &st.presented, + st.hdr, + presenter.hdr_active(), + ), + None => String::new(), + }; + st.osd_text = text; + } continue; } // F11 or Alt+Enter (some keyboards' Fn layer sends a media key for @@ -647,15 +672,27 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } SessionEvent::Stats(s) => { st.osd_text = stats_text( + stats_verbosity, &st.mode_line, &s, &st.presented, st.hdr, presenter.hdr_active(), ); - if print_stats { - println!("stats: {}", st.osd_text.replace('\n', " | ")); + if stats_verbosity != StatsVerbosity::Off { + // The stdout line is the machine interface (shell status card, + // scripts) — always the full Detailed text, whatever the OSD tier. + let full = stats_text( + StatsVerbosity::Detailed, + &st.mode_line, + &s, + &st.presented, + st.hdr, + presenter.hdr_active(), + ); + println!("stats: {}", full.replace('\n', " | ")); } + st.last_stats = Some(s); } SessionEvent::Failed { msg, @@ -728,7 +765,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result _ => None, }; ( - (print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()), + (stats_verbosity != StatsVerbosity::Off && !st.osd_text.is_empty()) + .then_some(st.osd_text.as_str()), hint, ) } @@ -996,45 +1034,138 @@ const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave"; -/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for -/// the console-UI panel; the stdout `stats:` line joins it with `|`. +/// The unified stats window (design/stats-unification.md) as OSD text at the given tier +/// (the Android client's vocabulary, each a strict superset of the previous): +/// Compact = one glanceable line, Normal = mode + end-to-end percentiles + loss, +/// Detailed = decoder path, HDR tag and the per-stage equation on top. Off reads empty. +/// Multi-line for the console-UI panel; the stdout `stats:` line joins Detailed with `|`. /// /// The HDR tag is honest about the display path: `HDR` only when the swapchain actually /// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10 /// format offered, HDR off in the compositor) shows `HDR→SDR` instead. fn stats_text( + verbosity: StatsVerbosity, mode_line: &str, s: &Stats, p: &PresentedWindow, hdr_stream: bool, hdr_display: bool, ) -> String { - let mut text = format!( - "{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}", - s.fps, - s.mbps, - if s.decoder.is_empty() { "-" } else { s.decoder }, - match (hdr_stream, hdr_display) { - (true, true) => " · HDR", - (true, false) => " · HDR→SDR", - _ => "", - }, - ); + match verbosity { + StatsVerbosity::Off => return String::new(), + StatsVerbosity::Compact => { + // fps · e2e ms · Mb/s — the latency term waits for the first presenter + // window (0 = no capture→displayed samples yet). + let mut text = format!("{:.0} fps", s.fps); + if p.e2e_p50_ms > 0.0 { + text.push_str(&format!(" · {:.1} ms", p.e2e_p50_ms)); + } + text.push_str(&format!(" · {:.0} Mb/s", s.mbps)); + if s.lost > 0 { + text.push_str(&format!(" · lost {}", s.lost)); + } + return text; + } + StatsVerbosity::Normal | StatsVerbosity::Detailed => {} + } + let detailed = verbosity == StatsVerbosity::Detailed; + let mut text = if detailed { + format!( + "{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}", + s.fps, + s.mbps, + if s.decoder.is_empty() { "-" } else { s.decoder }, + match (hdr_stream, hdr_display) { + (true, true) => " · HDR", + (true, false) => " · HDR→SDR", + _ => "", + }, + ) + } else { + format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps) + }; text.push_str(&format!( "\ne2e {:.1}/{:.1} ms (p50/p95)", p.e2e_p50_ms, p.e2e_p95_ms )); - if s.split { - text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms)); - } else { - text.push_str(&format!(" · host+net {:.1}", s.host_net_ms)); + if detailed { + if s.split { + text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms)); + } else { + text.push_str(&format!(" · host+net {:.1}", s.host_net_ms)); + } + text.push_str(&format!( + " · decode {:.1} · display {:.1} ms", + s.decode_ms, p.display_ms + )); } - text.push_str(&format!( - " · decode {:.1} · display {:.1} ms", - s.decode_ms, p.display_ms - )); if s.lost > 0 { text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct)); } text } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> (Stats, PresentedWindow) { + ( + Stats { + fps: 119.6, + mbps: 24.3, + host_net_ms: 2.1, + host_ms: 1.2, + net_ms: 0.9, + split: true, + decode_ms: 1.8, + lost: 3, + lost_pct: 0.4, + decoder: "vulkan", + }, + PresentedWindow { + e2e_p50_ms: 6.4, + e2e_p95_ms: 9.1, + display_ms: 1.1, + }, + ) + } + + /// The tier ladder: Off is empty, Compact is one line, Normal adds the mode + e2e + /// lines but no stage terms or decoder tag, Detailed carries everything. + #[test] + fn stats_text_tiers() { + let (s, p) = sample(); + let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false); + + assert_eq!(text(StatsVerbosity::Off), ""); + + let compact = text(StatsVerbosity::Compact); + assert_eq!(compact, "120 fps · 6.4 ms · 24 Mb/s · lost 3"); + assert_eq!(compact.lines().count(), 1); + + let normal = text(StatsVerbosity::Normal); + assert!(normal.starts_with("1920×1080@120 · 120 fps · 24.3 Mb/s\n")); + assert!(normal.contains("e2e 6.4/9.1 ms (p50/p95)")); + assert!(normal.contains("lost 3 (0.4%)")); + assert!(!normal.contains("vulkan"), "decoder tag is Detailed-only"); + assert!(!normal.contains("decode"), "stage terms are Detailed-only"); + + let detailed = text(StatsVerbosity::Detailed); + assert!(detailed.contains("vulkan · HDR→SDR")); + assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms")); + assert!(detailed.contains("lost 3 (0.4%)")); + } + + /// Compact omits the latency term until the presenter's first e2e window lands. + #[test] + fn compact_waits_for_e2e() { + let (mut s, _) = sample(); + s.lost = 0; + let p = PresentedWindow::default(); + assert_eq!( + stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false), + "120 fps · 24 Mb/s" + ); + } +}