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.
126 lines
6.4 KiB
Swift
126 lines
6.4 KiB
Swift
// The ink the gamepad UI draws with under the chosen background palette.
|
|
//
|
|
// The console screens were white-on-dark throughout with the brand violet hardcoded as the
|
|
// accent. Both had to become palette-derived at once: a pale field needs dark text or it is
|
|
// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix.
|
|
//
|
|
// Handed down the view tree as an environment value rather than passed to each screen, so a
|
|
// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between
|
|
// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`.
|
|
|
|
import PunktfunkShared
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
|
|
struct GamepadInk: Equatable, Sendable {
|
|
/// Primary text/glyph colour.
|
|
let fg: Color
|
|
/// Focus wash, selected tab pill, switch track, caret — the palette's own accent.
|
|
let accent: Color
|
|
/// What reads ON the accent (a filled pill's label).
|
|
let onAccent: Color
|
|
/// The base fill every glass surface starts from.
|
|
let glass: Color
|
|
/// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one.
|
|
let shade: Color
|
|
/// How hard those washes go. A pale field needs far less — mixing toward white at the dark
|
|
/// field's strength bleaches the chroma straight out of the gradient.
|
|
let shadeScale: Double
|
|
/// True when the field is pale, for the few places that need to branch rather than blend
|
|
/// (a material's `colorScheme`, a shadow's presence).
|
|
let isLight: Bool
|
|
|
|
/// The foreground at `alpha`.
|
|
func fg(_ alpha: Double) -> Color { fg.opacity(alpha) }
|
|
/// The accent at `alpha`.
|
|
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
|
|
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
|
|
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
|
|
/// The glass base at `alpha` — what a surface's material is washed with so it carries the
|
|
/// palette's hue (the console fills its panels with exactly this colour).
|
|
func glass(_ alpha: Double) -> Color { glass.opacity(alpha) }
|
|
/// A drop shadow: always black — a white shadow is not a shadow — but softened on a pale
|
|
/// field, where full-strength black under every card reads as a smear rather than depth.
|
|
func shadow(_ alpha: Double) -> Color { .black.opacity(alpha * (isLight ? 0.4 : 1)) }
|
|
|
|
static func of(_ p: GamepadPalette) -> GamepadInk {
|
|
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
|
|
let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z
|
|
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
|
|
// GLASS, not against the field.
|
|
let onAccent: Color = accentLuma > 0.55 ? .black : .white
|
|
guard p.light else {
|
|
return GamepadInk(
|
|
fg: .white, accent: accent, onAccent: onAccent,
|
|
glass: Color(red: 0.086, green: 0.086, blue: 0.125),
|
|
shade: .black, shadeScale: 1, isLight: false)
|
|
}
|
|
return GamepadInk(
|
|
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
|
|
fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20),
|
|
accent: accent, onAccent: onAccent,
|
|
glass: .white,
|
|
shade: .white, shadeScale: 0.45, isLight: true)
|
|
}
|
|
|
|
/// The shipped dark look — what a preview or a test composition gets.
|
|
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
|
|
|
|
/// The online pip — deliberately NOT palette-derived: a status colour must not change
|
|
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
|
|
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
|
|
}
|
|
|
|
private struct GamepadInkKey: EnvironmentKey {
|
|
static let defaultValue = GamepadInk.dark
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
/// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`).
|
|
var gamepadInk: GamepadInk {
|
|
get { self[GamepadInkKey.self] }
|
|
set { self[GamepadInkKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Resolve the stored `ui_palette` and publish its ink — AND the matching colour scheme — to
|
|
/// everything below. Applied by the gamepad screens' common root so no individual view has to
|
|
/// read the setting.
|
|
///
|
|
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
|
|
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
|
|
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
|
|
/// palette's scheme would invert their own system colours instead of matching them.
|
|
func gamepadPaletteInk(_ active: Bool = true) -> some View {
|
|
modifier(GamepadInkModifier(active: active))
|
|
}
|
|
}
|
|
|
|
private struct GamepadInkModifier: ViewModifier {
|
|
var active = true
|
|
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
|
/// The ambient scheme from ABOVE this modifier — what gets republished unchanged when the
|
|
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
|
|
/// that would change this view's identity.
|
|
@Environment(\.colorScheme) private var systemScheme
|
|
|
|
func body(content: Content) -> some View {
|
|
let palette = GamepadPalette.named(paletteID)
|
|
return content
|
|
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
|
|
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
|
|
// screens — `.secondary` in a placeholder, a `.bordered` button's chrome, a
|
|
// NavigationStack's title, a material's frost — resolves against the DEVICE's
|
|
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
|
|
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
|
|
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
|
|
// the interface was unreadable. Publishing the scheme here — once, beside the ink it
|
|
// has to agree with — is what makes a pale palette mean "light" to UIKit too.
|
|
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
|
|
}
|
|
}
|
|
|
|
#endif
|