apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.
A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.
A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.
The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.
Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.
That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.
Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
205 lines
9.7 KiB
Swift
205 lines
9.7 KiB
Swift
// GlassStyle.swift — the app's single, availability-gated entry point to Apple's "Liquid
|
|
// Glass" (iOS / macOS / tvOS 26). Every Liquid Glass symbol (glassEffect, Glass, the
|
|
// .glassProminent button style …) is HARD-gated to OS 26: referencing one with our
|
|
// deployment targets (macOS 14 / iOS 17 / tvOS 17) is a COMPILE error, not a silent no-op,
|
|
// unless it sits behind `if #available`. So all glass in the app routes through the two
|
|
// helpers below, each of which falls back to the EXACT look the app shipped before
|
|
// (.regularMaterial / .borderedProminent) — nothing regresses on older OSes, and the gating
|
|
// lives in exactly one file.
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - Glass background
|
|
|
|
/// Liquid Glass behind a floating / overlay surface, with the pre-26 `.regularMaterial`
|
|
/// look as the fallback. Use ONLY on the floating control / overlay layer (the streaming
|
|
/// HUD, the trust card, the touch exit chip) — never on content tiles or dense forms (HIG).
|
|
///
|
|
/// `glassEffect()`'s own default shape is a Capsule, so panels MUST pass an explicit shape
|
|
/// (a RoundedRectangle / Circle) or they render as a pill. `interactive` makes the glass
|
|
/// react to press — only meaningful when the glass itself is the tap target.
|
|
private struct GlassBackground<S: Shape>: ViewModifier {
|
|
let shape: S
|
|
var interactive = false
|
|
|
|
func body(content: Content) -> some View {
|
|
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
|
content.glassEffect(interactive ? .regular.interactive() : .regular, in: shape)
|
|
} else {
|
|
content.background(.regularMaterial, in: shape)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Liquid Glass (26+) or the existing `.regularMaterial` (pre-26) behind a floating
|
|
/// surface. Pass the surface's shape explicitly — glass defaults to a Capsule otherwise.
|
|
func glassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
|
|
modifier(GlassBackground(shape: shape, interactive: interactive))
|
|
}
|
|
}
|
|
|
|
// MARK: - Glass primary button
|
|
|
|
/// The single prominent action on a floating / overlay or sheet surface: the Liquid-Glass
|
|
/// prominent button style on 26+, falling back to `.borderedProminent` (the app's current
|
|
/// primary style) below. Apply directly to a `Button`; role / keyboardShortcut / disabled
|
|
/// chain after it as usual. tvOS stays `.borderedProminent` always — glass chrome fights the
|
|
/// focus engine, and keeping it preserves today's tvOS look exactly.
|
|
private struct GlassProminentButton: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
#if os(tvOS)
|
|
content.buttonStyle(.borderedProminent)
|
|
#else
|
|
if #available(iOS 26, macOS 26, *) {
|
|
content.buttonStyle(.glassProminent)
|
|
} else {
|
|
content.buttonStyle(.borderedProminent)
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Liquid-Glass prominent style (26+, non-tvOS) or `.borderedProminent`. Drop-in for the
|
|
/// `.buttonStyle(.borderedProminent)` on a surface's primary action.
|
|
func glassProminentButtonStyle() -> some View {
|
|
modifier(GlassProminentButton())
|
|
}
|
|
}
|
|
|
|
// MARK: - Console glass (gamepad host tiles + settings rows)
|
|
|
|
/// Liquid Glass tuned for the gamepad UI's "console" surfaces — the host-carousel tiles and
|
|
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
|
|
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
|
|
/// glass toward a color (the palette accent on the focused / primary surface); `interactive` makes
|
|
/// it flex on press.
|
|
///
|
|
/// Every tier is WASHED with the palette's `ink.glass` — the same surface colour the console
|
|
/// fills its panels with — so switching the background palette recolours the surfaces, not just
|
|
/// the text on them. The wash alphas are tune-on-device values with one fixed direction: the
|
|
/// pale palettes' white frost needs MORE body than the dark glass (the console's 0.66-vs-0.62
|
|
/// pair), because a thin white wash over a colourful field reads as haze, not as a surface.
|
|
private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|
let shape: S
|
|
var tint: Color?
|
|
var interactive = false
|
|
/// The console surface follows the background palette: a PALE field needs the material to
|
|
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
|
|
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
|
|
@Environment(\.gamepadInk) private var ink
|
|
|
|
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
|
/// The palette wash over the material tiers (the material itself supplies the blur body).
|
|
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
|
|
|
func body(content: Content) -> some View {
|
|
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
|
|
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
|
|
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
|
|
// against the device appearance — which is how the pale palettes came out light-on-light
|
|
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
|
|
// tvOS and pre-26 branches were the odd ones out.
|
|
#if os(tvOS)
|
|
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
|
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
|
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
|
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
|
content
|
|
.background {
|
|
shape.fill(.ultraThinMaterial)
|
|
.environment(\.colorScheme, scheme)
|
|
.overlay { shape.fill(materialWash) }
|
|
.overlay {
|
|
if let tint { shape.fill(tint) }
|
|
}
|
|
}
|
|
.environment(\.colorScheme, scheme)
|
|
#else
|
|
if #available(iOS 26, macOS 26, *) {
|
|
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
|
} else {
|
|
content
|
|
.background {
|
|
shape.fill(.ultraThinMaterial)
|
|
.environment(\.colorScheme, scheme)
|
|
.overlay { shape.fill(materialWash) }
|
|
.overlay {
|
|
if let tint { shape.fill(tint) }
|
|
}
|
|
}
|
|
.environment(\.colorScheme, scheme)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
@available(iOS 26, macOS 26, *)
|
|
private var glass: Glass {
|
|
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
|
|
// it: mixed 60 % toward the caller's (the focused row must still read accented on
|
|
// every palette) over the palette base. If device QA finds the mixed focus wash too
|
|
// weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit.
|
|
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
|
|
var g: Glass = .regular.tint(
|
|
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
|
|
if interactive { g = g.interactive() }
|
|
return g
|
|
}
|
|
#endif
|
|
}
|
|
|
|
extension View {
|
|
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
|
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
|
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
|
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
|
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
|
}
|
|
}
|
|
|
|
// MARK: - Console floating glass (the gamepad screens' close buttons)
|
|
|
|
/// `glassBackground` for a floating control INSIDE the gamepad UI (the close ✕): same shape
|
|
/// contract, but washed with the palette's ink and frosted to the palette's scheme — plain
|
|
/// `glassBackground` follows the SYSTEM appearance, which leaves the frost dark under dark ink
|
|
/// when a pale palette is up. The non-gamepad floating surfaces (the HUD, the trust card, the
|
|
/// touch connect modal) keep plain `glassBackground`: they sit over video or the touch UI,
|
|
/// where the palette means nothing.
|
|
private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
|
let shape: S
|
|
var interactive = false
|
|
@Environment(\.gamepadInk) private var ink
|
|
|
|
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
|
|
|
|
func body(content: Content) -> some View {
|
|
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
|
content
|
|
.glassEffect(
|
|
(interactive ? Glass.regular.interactive() : .regular)
|
|
.tint(ink.glass(ink.isLight ? 0.60 : 0.45)),
|
|
in: shape)
|
|
.environment(\.colorScheme, scheme)
|
|
} else {
|
|
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
|
|
content
|
|
.background {
|
|
shape.fill(.regularMaterial)
|
|
.environment(\.colorScheme, scheme)
|
|
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
|
}
|
|
.environment(\.colorScheme, scheme)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Palette-washed floating glass for the gamepad screens' own controls. Same fallback story
|
|
/// as `glassBackground` (`.regularMaterial` pre-26), plus the ink wash and scheme flip.
|
|
func consoleGlassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
|
|
modifier(ConsoleGlassBackground(shape: shape, interactive: interactive))
|
|
}
|
|
}
|