feat(clients): tiered stats overlay everywhere — Compact/Normal/Detailed on every platform
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
release / apple (push) Successful in 8m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 13m3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m39s
docker / deploy-docs (push) Successful in 18s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 14m54s
ci / rust (push) Successful in 23m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
flatpak / build-publish (push) Failing after 35s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 01:42:46 +02:00
parent 7b25868a19
commit 38b9f310e2
23 changed files with 528 additions and 119 deletions
@@ -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)
@@ -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
@@ -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
// capturereceived 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, capturereceived 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
/// capturereceived) 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)
@@ -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.",
@@ -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
@@ -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: {
@@ -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
@@ -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<String> {
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
}
private var hdrEnabledTag: Binding<String> {
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)
@@ -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
@@ -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
@@ -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"
@@ -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)
}
}
@@ -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
+1 -1
View File
@@ -670,7 +670,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title">Toggle statistics overlay</property>
<property name="title">Cycle the statistics overlay (off · compact · normal · detailed)</property>
<property name="accelerator">&lt;Control&gt;&lt;Alt&gt;&lt;Shift&gt;s</property>
</object>
</child>
+17 -7
View File
@@ -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();
+5 -2
View File
@@ -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.
+5 -1
View File
@@ -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));
+6 -1
View File
@@ -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.
+4 -1
View File
@@ -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",
+17 -9
View File
@@ -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
}),
+92 -2
View File
@@ -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<StatsVerbosity>,
/// 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]
+13 -3
View File
@@ -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()
}
+145 -14
View File
@@ -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<Stats>,
}
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<Option<Outcome>
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<OverlayFrame> = 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<Option<Outcome>
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<Option<Outcome>
}
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<Option<Outcome>
_ => 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,20 +1034,43 @@ 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!(
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,
@@ -1019,11 +1080,15 @@ fn stats_text(
(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 detailed {
if s.split {
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
} else {
@@ -1033,8 +1098,74 @@ fn stats_text(
" · 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"
);
}
}