Compare commits

...
Author SHA1 Message Date
enricobuehler 453b9850fa Merge pull request 'Gamepad UI: multi-tone palettes, and a UI that takes its colours from them' (#74) from worktree-gamepad-ui-polish into main
ci / bun-nix (push) Successful in 19s
ci / docs-site (push) Successful in 1m19s
apple / swift (push) Successful in 1m25s
deb / build-publish (push) Successful in 3m43s
ci / web (push) Successful in 5m20s
deb / build-publish-host (push) Successful in 4m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 6m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 9s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
deb / build-publish-client-arm64 (push) Successful in 2m14s
arch / build-publish (push) Successful in 7m7s
docker / deploy-docs (push) Failing after 9s
android / android (push) Failing after 10m28s
ci / rust (push) Failing after 10m7s
release / apple (push) Successful in 9m49s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / screenshots (push) Successful in 5m55s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
flatpak / build-publish (push) Failing after 13m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m13s
Reviewed-on: #74
2026-08-06 14:48:24 +00:00
enricobuehler eb4b33ca49 Merge pull request 'An xbox launch kind a de-privileged plugin can publish' (#75) from worktree-xbox-launch-kind into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Failing after 11m41s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #75
2026-08-06 14:48:03 +00:00
enricobuehler 8e02f3a8e1 Merge pull request 'ci(nix): a stale bun.nix can no longer reach main unnoticed' (#73) from worktree-nix-bun2nix-drift-gate into main
apple / swift (push) Successful in 1m26s
ci / bun-nix (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m27s
android / android (push) Canceled after 1m44s
deb / build-publish-host (push) Failing after 3s
apple / screenshots (push) Canceled after 10s
arch / build-publish (push) Canceled after 1m47s
ci / rust (push) Canceled after 1m49s
ci / web (push) Canceled after 1m45s
ci / docs-site (push) Canceled after 1m45s
deb / build-publish (push) Canceled after 26s
deb / build-publish-client-arm64 (push) Canceled after 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
nix / flake (push) Failing after 18s
decky / build-publish (push) Successful in 50s
Reviewed-on: #73
2026-08-06 14:46:25 +00:00
enricobuehler 4a9a1c3ed4 feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m59s
android / android (pull_request) Successful in 5m53s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 9m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m41s
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.

Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.

`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.

The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.

Three things only the renders could have told us:

  - Additive blending blows out over a pale ground. Android's blobs and Apple's
    legacy field composite with Plus/plusLighter, which over near-white
    saturates every blob to white — Holo rendered as a grey wash. Pale palettes
    blend normally.
  - A white scrim at the dark field's strength BLEACHES the gradient. Mixing
    toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
    it is drawn over. The scrim now carries a per-palette strength.
  - White glass over a bright field has far less separating it from its backdrop
    than dark glass over a dark one, and needed more body.

Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
2026-08-06 15:55:26 +02:00
enricobuehler 166e158afe feat(host/library): an xbox launch kind a de-privileged plugin can publish
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m22s
android / android (pull_request) Successful in 3m9s
ci / docs-site (pull_request) Successful in 1m9s
ci / web (pull_request) Successful in 3m20s
ci / rust (pull_request) Failing after 9m9s
The xbox scanner resolves each package's PackageFamilyName by enumerating
%ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1),
that directory is:

  NT AUTHORITY\LocalService   UnauthorizedAccessException   <- the plugin runner
  LocalSystem                 count=348                     <- the host service

So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete
`<PFN>!<AppId>` — is unpublishable by one.

The design doc offered two fallbacks: entries without `aumid` (they would list but not
launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin
sends `<Identity>!<AppId>`, both read straight out of MicrosoftGame.config — which IS
readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it
already owns command construction.

That keeps D1 intact (the plugin supplies a validated value, the host builds the command),
it is the same shape as the `playnite` kind, and resolving late means a package update that
changes the publisher hash cannot leave a stale unlaunchable tile behind — which the
install-time cache would have.

`xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted,
and its doc records the privilege asymmetry so the next reader doesn't re-derive it.

The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid`
kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is
load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into
a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is
a 400 the plugin author can act on rather than a tile that fails on click.

Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173
(where these arms actually compile). Both counts +1 for the new guard test.
2026-08-06 15:43:26 +02:00
enricobuehler fb707b4956 ci(nix): a stale bun.nix can no longer reach main unnoticed
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Failing after 2m19s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 3m10s
ci / bun-nix (pull_request) Successful in 1m6s
nix / flake (pull_request) Failing after 1m6s
ci / rust (pull_request) Failing after 6m2s
Moving the bun packages to bun2nix (1db8f763) removed the aggregate deps hash
that used to go stale, but not the second, quieter way a generated file rots.
bun2nix is a pure function of bun.lock, and `bun.nix` is committed alongside it
— but it regenerates ONLY from a local `bun install` that runs lifecycle
scripts. It does not regenerate under `bun install --ignore-scripts`, which is
what every bun install in CI uses, and it cannot regenerate on a merge or
rebase, where git carries someone else's lockfile change past a `bun.nix`
generated before it and reports no conflict.

That is not theoretical. web/bun.nix went onto main holding brace-expansion@5.0.7
(plus two nested entries the `^5.0.8` override had already collapsed) while
web/bun.lock said 5.0.8: the override landed in ec9aa415, the bun2nix branch had
generated bun.nix off the pre-override lock, and the merge kept both. The Nix
build fetches node_modules strictly from bun.nix, so the derivation's offline
`bun install` was asked for a tarball its store cache did not contain and
`nix build .#punktfunk-web` failed. It stayed broken for 553 commits
(2026-07-27 -> 2026-08-05) and was closed by accident, when an unrelated
advisory bump in b79d90b4 happened to rerun a real `bun install`.

Nothing caught it because nothing could: the string "nix" appeared in exactly
one workflow file, and only in a comment about bun2nix breaking a Windows step.

  * scripts/ci/check-bun-nix.sh regenerates each bun.nix from its committed
    bun.lock and diffs, with `--fix` to rewrite them. It reads which packages to
    check out of packages.nix's own `bunNix = src + ...` lines rather than a
    hardcoded list, so a third bun package is covered the day it is added, and
    an empty list is a hard error — a gate that checks nothing passes exactly
    like a clean tree. It also enforces the bun2nix version pin that flake.nix
    and the README only stated: bun.nix has no schema stability across releases,
    so the flake input ref and both package.json devDependencies must agree, and
    generation always uses the pinned version rather than a floating `bunx`.

  * ci.yml gains a `bun-nix` job running it. Deliberately unfiltered and not in
    nix.yml: it needs no Nix, takes under a minute, and the whole point is that
    this drift arrives through commits that look unrelated to Nix.

  * nix.yml is new and is the first CI that evaluates the flake at all —
    `nix flake check --no-build` over every output, then a real build of
    punktfunk-web + punktfunk-scripting, which are the two derivations whose
    inputs churn and the only ones that do not compile Rust. That leg is the
    end-to-end proof the drift gate cannot give: it catches a tarball the
    registry no longer serves, or the codegen going quietly message-less. It is
    path-filtered, has a workflow_dispatch opt-in for the hour-long Rust
    packages, and keeps `pull_request` — flatpak.yml shipped push-only and let
    manifest breakage reach main invisibly for weeks.

The script is POSIX sh (shellcheck-clean, exercised under dash, which is the
CI container's /bin/sh) and avoids process substitution, the parse-time failure
that silently disabled ci.yml's shader SPIR-V gate for 35 commits.
2026-08-06 15:28:14 +02:00
enricobuehler 65996621d8 Merge pull request 'fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate' (#72) from worktree-pyrowave-gpu-priority into main
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m26s
deb / build-publish-host (push) Failing after 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 1m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Failing after 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 10s
apple / swift (push) Successful in 1m27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 12s
deb / build-publish-client-arm64 (push) Successful in 2m55s
docker / builders-arm64cross (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish (push) Successful in 4m9s
docker / deploy-docs (push) Successful in 42s
arch / build-publish (push) Successful in 10m31s
ci / rust-arm64 (push) Failing after 8m55s
apple / screenshots (push) Successful in 5m51s
windows-host / package (push) Failing after 6m15s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 9m13s
ci / rust (push) Successful in 20m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
Reviewed-on: #72
2026-08-06 13:15:06 +00:00
enricobuehler c48e60fbb7 Merge pull request 'Two July fixes that were never merged: rpm FFmpeg modules + the released-pointer double cursor' (#71) from worktree-july-rpm-and-cursor into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m48s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
release / apple (push) Successful in 9m31s
flatpak / build-publish (push) Successful in 10m3s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
Reviewed-on: #71
2026-08-06 13:13:33 +00:00
enricobuehler 70684e5079 fix(encode/pyrowave): stop stamping GPU scheduling priority over pf-frame's auto gate
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m41s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / web (pull_request) Successful in 2m27s
ci / docs-site (pull_request) Successful in 3m35s
ci / rust (pull_request) Successful in 8m25s
`windows/pyrowave.rs` raised the process's WDDM scheduling class to HIGH itself, once per
process, at every session open. `pf-frame::dxgi::auto_priority_gate` already owns that policy
for the whole process and runs from `create_device` — the call the Windows capture path always
makes before any PyroWave texture exists. Two owners of one process-wide setting.

The audit filed this as "downgrades REALTIME to HIGH", which undersells it. pf-frame's default
`auto` mode starts at HIGH and then UPGRADES to REALTIME once it has established that is safe —
HAGS off, or HAGS on with VRAM headroom — and leaves a monitor running that drops back when VRAM
tightens, because REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang. Opening
a PyroWave session after that upgrade stamped HIGH back over the class AND orphaned the
monitor's decision, losing the ceiling-raise on exactly the GPU-saturated workload PyroWave
exists to survive: it encodes on the shader cores a game saturates, where the measured spike is
~2 ms to 15-18 ms.

Removed rather than reconciled. `PyroWaveEncoder::open` takes no device, so there was nothing
session-specific to preserve, and the surviving owner is strictly better informed — it knows the
adapter, HAGS state and VRAM headroom, none of which this call site had.

The duplicated knob goes with it: `PUNKTFUNK_GPU_PRIORITY` is retired in favour of
`PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default `auto`), which is a
superset — the removed knob could not express the auto gate at all. No other reference to it
exists in the tree.

Verified on .173: clippy -D warnings at nvenc,amf-qsv,qsv (host + pf-encode --all-targets),
amf-qsv without qsv, qsv alone, no-features, cargo test --features qsv (34 passed), rustfmt —
7 legs green. Windows-only file, so the Linux legs do not compile it.
2026-08-06 15:06:49 +02:00
enricobuehler 3f8a70dd45 Merge pull request 'Launcher tiles reach the clients, and Playnite can publish again' (#70) from worktree-library-clients into main
apple / swift (push) Successful in 1m34s
ci / web (push) Successful in 1m7s
deb / build-publish-host (push) Failing after 4s
ci / docs-site (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m36s
ci / rust-arm64 (push) Successful in 5m47s
arch / build-publish (push) Successful in 7m36s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 25s
android / android (push) Canceled after 9m43s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 9m54s
deb / build-publish (push) Canceled after 8m14s
release / apple (push) Canceled after 8m21s
flatpak / build-publish (push) Canceled after 5m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / package (push) Failing after 6m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
Reviewed-on: #70
2026-08-06 13:03:42 +00:00
enricobuehler 58ee74cb58 fix(clients/windows): clippy's manual_is_multiple_of on the rescan tick
android / android (pull_request) Successful in 6m8s
ci / web (pull_request) Successful in 4m50s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 5m50s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m37s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.

Clippy's own suggestion, applied verbatim.
2026-08-06 14:50:18 +02:00
enricobuehler 6ae2ea6708 Merge remote-tracking branch 'origin/main' into worktree-library-clients 2026-08-06 14:45:36 +02:00
enricobuehler dd20a17edb test(host/library): the art tests build a file:// URL Windows can read
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
2026-08-06 14:36:04 +02:00
enricobuehler 8ff2c2e1c6 feat(host/library): Playnite can publish again, and gets a fullscreen tile
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
2026-08-06 14:35:48 +02:00
enricobuehler 883c317872 feat(clients/library): a launcher tile looks like one, on every client
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
2026-08-06 14:35:24 +02:00
59 changed files with 2758 additions and 865 deletions
+28
View File
@@ -275,3 +275,31 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
# which happily carries a lockfile change past a bun.nix generated before it. That is not
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
# happened to rerun a real `bun install`.
#
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
# minute, and the whole point is that the drift arrives through commits that look unrelated to
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
bun-nix:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
+132
View File
@@ -0,0 +1,132 @@
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
# in ci.yml for that story).
#
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
# the generated bun.nix really does materialise a working node_modules offline — it
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
#
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
name: nix
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
workflow_dispatch:
inputs:
build-rust:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
jobs:
flake:
runs-on: ubuntu-24.04
container:
# Official Nix image (Docker Hub, like this fleet's other WAN images: oven/bun:1,
# fedora:43, node:22-bookworm). It ships nix and little else.
image: nixos/nix:latest
timeout-minutes: 90
env:
# The image defaults to stable Nix with the experimental features off; the flake needs both.
# Set at job level so every step — including the `nix profile install` below — sees it.
NIX_CONFIG: "experimental-features = nix-command flakes"
steps:
# actions/checkout is a JS action and needs node; a plain `run:` step executes through the
# container shell, so this must come BEFORE the checkout (same ordering, and the same
# reason, as flatpak.yml's fedora job).
- name: node + git for the JS actions
run: nix profile install nixpkgs#nodejs nixpkgs#git
- uses: actions/checkout@v4
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
# ("detected dubious ownership"), which is the normal case for a container job.
- name: Trust the checkout
run: git config --global --add safe.directory "$PWD"
# First-run diagnostics — cheap, and the difference between "the gate found a real problem"
# and "the runner had no disk" is otherwise a guess.
- name: Environment
run: |
nix --version
df -h /nix /tmp || true
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
run: nix flake check --no-build --show-trace
# The bun packages, built for real. This is the leg that would have caught the stale
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
- name: Build the bun packages
run: nix build --print-build-logs .#punktfunk-web .#punktfunk-scripting
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
# points rather than dangling wrappers.
- name: Smoke the built launchers
run: |
set -eu
web=$(nix path-info .#punktfunk-web)
scripting=$(nix path-info .#punktfunk-scripting)
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
# The console must be the bun bundle, not a node one — the same assertion packages.nix
# makes at build time, re-checked on the installed output.
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
echo "bun packages OK: $web $scripting"
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
- name: Build the Rust packages (dispatch opt-in)
if: ${{ github.event.inputs.build-rust == 'true' }}
run: nix build --print-build-logs .#punktfunk-host .#punktfunk-client
@@ -109,8 +109,10 @@ fun App(forceGamepadUi: Boolean = false) {
// threaded through every screen that draws a backdrop. Because it is read from the SAME
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
// the field behind that very row.
val palette = GamepadPalette.named(settings.uiPalette)
CompositionLocalProvider(
LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette),
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
AnimatedContent(
targetState = session,
@@ -191,6 +191,7 @@ internal fun ConnectTakeover(
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val ink = LocalGamepadInk.current
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
@@ -212,7 +213,7 @@ internal fun ConnectTakeover(
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
tint = ink.fg(0.9f),
modifier = Modifier.size(46.dp),
)
}
@@ -221,14 +222,14 @@ internal fun ConnectTakeover(
}
Text(
copy.title,
color = Color.White,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = Color.White.copy(alpha = 0.65f),
color = ink.fg(0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
@@ -249,6 +250,7 @@ internal fun ConnectTakeover(
*/
@Composable
private fun PulsingSpinner() {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
@@ -262,14 +264,14 @@ private fun PulsingSpinner() {
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = Color.White,
color = ink.fg,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
@@ -79,6 +79,7 @@ fun GamepadAddHostScreen(
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
@@ -245,7 +246,7 @@ fun GamepadAddHostScreen(
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
color = ink.fg(0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
@@ -306,6 +307,7 @@ private fun TvAddHostForm(
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
@@ -319,11 +321,11 @@ private fun TvAddHostForm(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
color = ink.fg(0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
@@ -362,6 +364,7 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
@@ -375,25 +378,26 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = Color(0xFF8678F5))
if (editing) Text(" |", color = ink.accent)
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
if (enabled) ink.accent else ink.fg(0.35f),
tween(160),
label = "addLabel",
)
@@ -425,6 +429,7 @@ private fun KeyboardGrid(
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
@@ -433,7 +438,7 @@ private fun KeyboardGrid(
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
.border(1.dp, ink.fg(0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
@@ -454,14 +459,15 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
if (focused) ink.accent else ink.glass,
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
@@ -72,9 +72,12 @@ import kotlin.math.sin
// connected-controller status chip. One look across every screen is what makes the console UI read
// as a coherent mode rather than a set of themed pages.
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
/**
* One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
* draw time, so the field always shows several of that palette's tones at once.
*/
private class AuroraBlob(
val color: Color,
val baseX: Float,
val baseY: Float,
val driftX: Float,
@@ -87,35 +90,33 @@ private class AuroraBlob(
)
private val auroraBlobs = listOf(
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
)
/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */
private val auroraBase = Color(0xFF131126)
/**
* The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless
* loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose
* approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience,
* never content" role, and the same [GamepadPalette] setting recolours both.
* The living console backdrop: soft blobs from the palette's ramp drifting over its ground on
* slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims.
* A Compose approximation of the Apple client's MeshGradient aurora — same colour families, same
* "ambience, never content" role, and the same [GamepadPalette] setting recolours both.
*
* [calm] is what the FORM screens wear: the pools dim onto the base so the glass rows keep real
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose
* only the contrast differs, so moving between screens can't make the field jump.
* [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose
* only the contrast differs, so moving between screens can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
val ink = LocalGamepadInk.current
val palette = LocalGamepadPalette.current
val animated = animationsEnabled()
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
// so the field never visibly jumps when the animation restarts.
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
// wrap so the field never visibly jumps when the animation restarts.
val swept by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
@@ -123,39 +124,45 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
label = "angle",
)
val angle = if (animated) swept else 0f
// Tinting is per-frame-cheap but not free, and the palette changes about once a year.
val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } }
val base = remember(palette.id) { palette.tint(auroraBase) }
val tones = palette.blobColors
val ground = palette.groundColor
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
// half — the same scrim strength the desktop console's shader carries.
val scrim = if (palette.light) ink.fg else Color.Black
val strength = if (palette.light) 0.45f else 1f
Canvas(modifier) {
drawRect(if (calm) base else Color.Black)
drawRect(ground)
val span = max(size.width, size.height)
for ((b, tinted) in blobs) {
for ((i, b) in auroraBlobs.withIndex()) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
// Calm scales each blob's contribution rather than dimming the whole canvas: the base
// stays put and only the pools come down to meet it, which is the same "lower the
// contrast, keep the colour" the desktop console's `calm` uniform does.
// Calm scales each blob's contribution rather than dimming the whole canvas: the
// ground stays put and only the pools come down to meet it, which is the same "lower
// the contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(tinted.copy(alpha = alpha), Color.Transparent),
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
blendMode = BlendMode.Plus,
// Additive only works over a DARK ground; over a pale one every blob
// saturates to white and the field turns grey. Pale palettes tint instead.
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, sink the corners. Halved under calm: a launcher's
// cards sit in the pooled centre, but a form screen's rows run out toward the edges, where
// crushing to black just eats them. (Matches the Apple client and the desktop console.)
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
// toward the edges, where crushing them just eats the list.
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = if (calm) 0.22f else 0.44f),
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
@@ -164,10 +171,10 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to Color.Black.copy(alpha = 0.40f),
0.30f to Color.Black.copy(alpha = 0.05f),
0.70f to Color.Black.copy(alpha = 0.06f),
1.0f to Color.Black.copy(alpha = 0.42f),
0.0f to scrim.copy(alpha = 0.40f * strength),
0.30f to scrim.copy(alpha = 0.05f * strength),
0.70f to scrim.copy(alpha = 0.06f * strength),
1.0f to scrim.copy(alpha = 0.42f * strength),
),
)
}
@@ -223,6 +230,7 @@ fun ConsoleTabStrip(
*/
focused: Boolean = false,
) {
val ink = LocalGamepadInk.current
val listState = rememberLazyListState()
LaunchedEffect(selected) {
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
@@ -236,17 +244,18 @@ fun ConsoleTabStrip(
itemsIndexed(titles) { i, title ->
val active = i == selected
val background by animateColorAsState(
if (active) Color(0xD96656F2) else Color(0x14FFFFFF),
if (active) ink.accent(0.85f) else ink.glass,
tween(180),
label = "tabBg",
)
val ink by animateColorAsState(
Color.White.copy(alpha = if (active) 1f else 0.55f),
// Not `ink` — that name is the palette's, and shadowing it here cost a compile.
val labelColor by animateColorAsState(
if (active) ink.onAccent else ink.fg(0.55f),
tween(180),
label = "tabInk",
)
val ring by animateColorAsState(
Color.White.copy(alpha = if (active && focused) 0.85f else 0f),
ink.fg(if (active && focused) 0.85f else 0f),
tween(180),
label = "tabRing",
)
@@ -254,7 +263,7 @@ fun ConsoleTabStrip(
title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = ink,
color = labelColor,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(50))
@@ -283,6 +292,7 @@ val ConsoleEdgeInset = 24.dp
*/
@Composable
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
val ink = LocalGamepadInk.current
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
@@ -290,7 +300,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
@@ -347,21 +357,22 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
if (active) ink.accent(0.20f) else ink.glass,
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
editing -> ink.accent(0.70f)
active -> ink.fg(0.28f)
else -> ink.fg(0.06f)
},
tween(160),
label = "consoleBorder",
@@ -376,18 +387,19 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
if (on) ink.accent else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
ink.fg(if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
@@ -409,7 +421,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(Color.White),
.background(ink.fg),
)
}
}
@@ -417,6 +429,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier
.size(size)
@@ -426,7 +439,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
) {
Text(
glyph.toString(),
color = Color.White,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.52f).sp,
textAlign = TextAlign.Center,
@@ -437,11 +450,12 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
@Composable
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape))
}
}
@@ -506,6 +520,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp)
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
@@ -517,17 +532,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
.border(1.4.dp, ink.fg(0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
.border(1.4.dp, ink.fg(0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = Color.White,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
@@ -536,7 +551,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
.border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)),
)
}
}
@@ -548,6 +563,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
*/
@Composable
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
val ink = LocalGamepadInk.current
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
@@ -560,13 +576,13 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
val frosted = if (hazeState != null) {
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f))
} else {
modifier.clip(shape).background(Color(0x8C14122A))
modifier.clip(shape).background(ink.shade(0.55f))
}
Row(
modifier = frosted
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
.border(1.dp, ink.fg(0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp)
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
// screen whose legend grew a cell) it scrolls rather than running off the edge and
@@ -598,7 +614,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
Text(
h.text,
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.9f),
color = ink.fg(0.9f),
maxLines = 1,
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
)
@@ -610,24 +626,25 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
@Composable
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
Row(
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.08f))
.background(ink.fg(0.08f))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.SportsEsports,
contentDescription = null,
tint = Color.White.copy(alpha = 0.75f),
tint = ink.fg(0.75f),
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(7.dp))
Text(
name,
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.75f),
color = ink.fg(0.75f),
maxLines = 1,
)
}
@@ -85,6 +85,7 @@ fun GamepadDialog(
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
val ink = LocalGamepadInk.current
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
@@ -117,11 +118,11 @@ fun GamepadDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -139,6 +140,7 @@ fun GamepadDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
@@ -152,19 +154,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
focused -> ink.accent
primary -> ink.accent(0.20f)
else -> ink.glass
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
!enabled -> ink.fg(0.35f)
focused -> ink.fg
primary -> ink.accent
else -> ink.fg(0.85f)
},
tween(160),
label = "btnFg",
@@ -198,7 +200,8 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
val ink = LocalGamepadInk.current
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
}
/**
@@ -271,6 +274,7 @@ fun GamepadPinHostsDialog(
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
@@ -304,7 +308,7 @@ fun GamepadPinHostsDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
@@ -312,7 +316,7 @@ fun GamepadPinHostsDialog(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -350,6 +354,7 @@ fun GamepadPinHostsDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
@@ -376,7 +381,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -518,6 +523,7 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
@@ -525,8 +531,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
Text("Approve this device on $hostLabel.", color = Color.White)
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
@@ -542,6 +548,7 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val ink = LocalGamepadInk.current
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
@@ -587,16 +594,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
@@ -615,13 +622,14 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
.background(if (focused) ink.accent(0.20f) else ink.glass)
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
}
}
@@ -247,9 +247,10 @@ fun GamepadHome(
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
@@ -258,7 +259,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.border(1.dp, ink.fg(0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
@@ -269,7 +270,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = Color.White.copy(alpha = 0.7f),
tint = ink.fg(0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
@@ -286,14 +287,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
color = ink.fg(0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -302,9 +303,10 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
@Composable
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
Brush.verticalGradient(listOf(ink.accent, ink.accent))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
@@ -316,18 +318,18 @@ private fun MonogramBadge(tile: HomeTile) {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = Color.White,
color = ink.fg,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
tint = if (tile.filled) ink.fg else ink.accent,
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) Color.White else Color(0xFF8678F5),
color = if (tile.filled) ink.fg else ink.accent,
)
}
}
@@ -0,0 +1,89 @@
package io.unom.punktfunk
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
// The ink the console (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.
//
// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card)
// can ask for the right colour without every caller in between knowing about palettes. The Apple
// client uses an environment value and `pf-console-ui` a thread-local for the same reason.
/** Everything about the console's look that follows the chosen palette. */
class GamepadInk(
/** Primary text/glyph colour. */
val fg: Color,
/** Focus wash, selected tab pill, switch track — the palette's own accent. */
val accent: Color,
/** What reads ON the accent (a filled pill's label, a switch knob). */
val onAccent: Color,
/** The base fill every glass surface starts from, at its resting opacity. */
val glass: Color,
/** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */
val 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.
*/
val shadeScale: Float,
/** True when the field is pale, for the few places that branch rather than blend. */
val isLight: Boolean,
) {
/** The foreground at [alpha]. */
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
/** The accent at [alpha]. */
fun accent(alpha: Float): Color = accent.copy(alpha = alpha)
/** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */
fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale)
companion object {
fun of(p: GamepadPalette): GamepadInk {
val accent = p.accentColor
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
val accentLuma =
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
if (!p.light) {
return GamepadInk(
fg = Color.White,
accent = accent,
onAccent = onAccent,
glass = Color.White.copy(alpha = 0.08f),
shade = Color.Black,
shadeScale = 1f,
isLight = false,
)
}
val (gr, gg, gb) = p.ground
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
accent = accent,
onAccent = onAccent,
// More body than the dark glass carries: white frost over a bright gradient has
// far less separating it from its backdrop than dark glass over a dark one.
glass = Color.White.copy(alpha = 0.55f),
shade = Color.White,
shadeScale = 0.45f,
isLight = true,
)
}
/** The shipped dark look — what a preview or a test composition gets. */
val DARK = of(GamepadPalette.named("violet"))
}
}
/**
* The ink of the palette currently drawing, for everything under [App]. Provided from the live
* settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks
* every console surface at once.
*/
val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK }
@@ -1,78 +1,194 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
// The console (gamepad) UI's background colour families.
// The console (gamepad) UI's background colour families, and the ink each one calls for.
//
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its
// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look —
// `violet` is the identity transform.
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
// field samples that ramp so several tones show at once and pool into each other, the way a real
// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every
// non-default palette read flat and monotone.
//
// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting
// names the same colour family on every client. Keep the three copies in step: a palette added
// here without the others is a value the other clients will silently render as Violet.
// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch
// colour, and [light] flips the ink so a pale field gets dark text instead of white.
//
// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look
// on every client. Keep the three copies in step: a palette added here without the others is a
// value the other clients silently render as Violet.
/**
* One background colour family. [hueDegrees] rotates about the grey axis (positive runs
* red → green → blue) and [saturation] scales saturation about luminance.
*/
/** One background colour family. */
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
val id: String,
/** What the settings row shows. */
val name: String,
val hueDegrees: Double,
val saturation: Double,
) {
/** True for the identity transform, so the default path skips the per-colour work. */
val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0
/** Apply this palette to one packed sRGB colour, keeping its alpha. */
fun tint(c: Color): Color {
if (isIdentity) return c
val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble()))
return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha)
}
/**
* Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same
* orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale
* its saturation about luminance. Clamped, because a large rotation can push a channel out of
* gamut.
* The colour ramp, dark end first. Empty = the brand default's explicit field, kept
* bit-identical to what every install already sees.
*/
fun tint(c: Triple<Double, Double, Double>): Triple<Double, Double, Double> {
val (r, g, b) = c
val a = Math.toRadians(hueDegrees)
val cs = cos(a)
val sn = sin(a)
val invSqrt3 = 1.0 / sqrt(3.0)
val grey = (r + g + b) / 3.0 * (1.0 - cs)
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
val rr = r * cs + (b - g) * invSqrt3 * sn + grey
val rg = g * cs + (r - b) * invSqrt3 * sn + grey
val rb = b * cs + (g - r) * invSqrt3 * sn + grey
val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb
fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0)
return Triple(mix(rr), mix(rg), mix(rb))
val stops: List<Triple<Double, Double, Double>>,
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
val ground: Triple<Double, Double, Double>,
/** The UI accent: focus wash, selected tab pill, switch track. */
val accent: Triple<Double, Double, Double>,
/** A pale field: the UI flips to dark ink and the legibility scrims go white. */
val light: Boolean,
) {
/** Four drifting blob colours, spread across the ramp so the field shows several hues. */
val blobColors: List<Color> by lazy {
val s = stops.ifEmpty { VIOLET_BLOBS }
(0..3).map { color(ramp(s, 0.15 + 0.25 * it)) }
}
/** The field's ground as a Compose colour. */
val groundColor: Color by lazy { color(ground) }
/** The accent as a Compose colour. */
val accentColor: Color by lazy { color(accent) }
companion object {
/**
* The six shipped palettes, in cycling order: the brand violet, then cool → warm, then
* the neutral.
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
* here so the three ports stay one table even though this client approximates the field
* with blobs.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
)
/** The brand default's blob ramp — the colours the pre-palette field used. */
private val VIOLET_BLOBS = listOf(
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96),
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
*/
val ALL = listOf(
GamepadPalette("violet", "Violet", 0.0, 1.0),
GamepadPalette("tide", "Tide", -70.0, 1.0),
GamepadPalette("forest", "Forest", -130.0, 0.9),
GamepadPalette("ember", "Ember", 105.0, 1.0),
GamepadPalette("rose", "Rose", 60.0, 0.95),
GamepadPalette("graphite", "Graphite", 0.0, 0.12),
// --- dark fields (white ink) ---
GamepadPalette(
"violet", "Violet", emptyList(),
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
listOf(
Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72),
Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68),
),
ground = Triple(0.055, 0.040, 0.135),
accent = Triple(0.95, 0.42, 0.72), light = false,
),
GamepadPalette(
// Ink-blue water: teal → cerulean → a violet undertow.
"abyss", "Abyss",
listOf(
Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63),
Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58),
),
ground = Triple(0.018, 0.070, 0.130),
accent = Triple(0.26, 0.76, 0.92), light = false,
),
GamepadPalette(
// Banked coals: plum embers → crimson → burnt orange → gold.
"ember", "Ember",
listOf(
Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06),
Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18),
),
ground = Triple(0.090, 0.035, 0.040),
accent = Triple(0.98, 0.62, 0.26), light = false,
),
GamepadPalette(
// Forest floor into moss and a lime break.
"moss", "Moss",
listOf(
Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31),
Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31),
),
ground = Triple(0.025, 0.085, 0.070),
accent = Triple(0.48, 0.86, 0.46), light = false,
),
GamepadPalette(
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone.
"graphite", "Graphite",
listOf(
Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35),
Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49),
),
ground = Triple(0.055, 0.055, 0.070),
accent = Triple(0.78, 0.80, 0.86), light = false,
),
// --- pale fields (dark ink) ---
GamepadPalette(
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
"holo", "Holo",
listOf(
Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99),
Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00),
),
ground = Triple(0.96, 0.92, 0.99),
accent = Triple(0.42, 0.28, 0.86), light = true,
),
GamepadPalette(
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
"sunset", "Sunset",
listOf(
Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34),
Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22),
),
ground = Triple(0.98, 0.74, 0.34),
accent = Triple(0.64, 0.13, 0.44), light = true,
),
GamepadPalette(
// Peach into blush and lilac — the softest of the set.
"bloom", "Bloom",
listOf(
Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89),
Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99),
),
ground = Triple(0.99, 0.90, 0.89),
accent = Triple(0.72, 0.24, 0.55), light = true,
),
GamepadPalette(
// First light: pale gold → coral → lilac.
"dawn", "Dawn",
listOf(
Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62),
Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95),
),
ground = Triple(1.00, 0.93, 0.82),
accent = Triple(0.82, 0.33, 0.28), light = true,
),
GamepadPalette(
// Sea glass: mint → aqua → a pale sky.
"mint", "Mint",
listOf(
Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95),
Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00),
),
ground = Triple(0.90, 0.98, 0.96),
accent = Triple(0.04, 0.42, 0.40), light = true,
),
GamepadPalette(
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
"opal", "Opal",
listOf(
Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95),
Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99),
),
ground = Triple(0.97, 0.96, 0.99),
accent = Triple(0.36, 0.32, 0.44), light = true,
),
)
/**
@@ -80,5 +196,23 @@ class GamepadPalette(
* palette a newer client shipped, not a reason to draw nothing.
*/
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
/** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */
fun ramp(
stops: List<Triple<Double, Double, Double>>,
t: Double,
): Triple<Double, Double, Double> {
if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0)
if (stops.size == 1) return stops[0]
val x = t.coerceIn(0.0, 1.0) * (stops.size - 1)
val i = x.toInt().coerceAtMost(stops.size - 2)
val f = x - i
val (ar, ag, ab) = stops[i]
val (br, bg, bb) = stops[i + 1]
return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f)
}
fun color(c: Triple<Double, Double, Double>): Color =
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
}
}
@@ -346,6 +346,7 @@ fun GamepadSettingsScreen(
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
@@ -357,7 +358,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
label = "chevrons",
)
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
ink.fg(if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
@@ -366,7 +367,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.45f),
color = ink.fg(0.45f),
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
@@ -392,7 +393,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
color = ink.fg(if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -400,7 +401,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
@@ -421,7 +422,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
@@ -434,7 +435,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
color = ink.fg(0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
@@ -90,6 +90,7 @@ fun LibraryScreen(
onBack: () -> Unit,
navActive: Boolean = true,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -177,8 +178,8 @@ fun LibraryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
CircularProgressIndicator(color = Color.White)
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = ink.fg)
Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -202,17 +203,19 @@ fun LibraryScreen(
@Composable
private fun LoadingState() {
val ink = LocalGamepadInk.current
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
CircularProgressIndicator(color = Color.White)
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = ink.fg)
Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun MessageState(text: String) {
val ink = LocalGamepadInk.current
Text(
text,
color = Color.White.copy(alpha = 0.75f),
color = ink.fg(0.75f),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
@@ -226,6 +229,7 @@ private fun Coverflow(
navActive: Boolean,
onLaunch: (GameEntry) -> Unit,
) {
val ink = LocalGamepadInk.current
BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
@@ -248,7 +252,22 @@ private fun Coverflow(
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
)
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
// library actually has both groups — otherwise the screen is exactly what it was.
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
if (bothGroups) {
Text(
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(coverWidth),
@@ -306,15 +325,16 @@ private fun Coverflow(
current?.title ?: " ",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (current != null) {
Text(
if (current.isCustom) "CUSTOM" else "STEAM",
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
else current.storeLabel.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.5f),
color = ink.fg(0.5f),
letterSpacing = 2.sp,
)
}
@@ -326,6 +346,7 @@ private fun Coverflow(
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
@Composable
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val candidates = game.art.posterCandidates
var idx by remember(game.id) { mutableStateOf(0) }
val shape = RoundedCornerShape(16.dp)
@@ -333,7 +354,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
modifier = modifier
.clip(shape)
.background(Color(0xFF241F3D))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
.border(1.dp, ink.fg(0.12f), shape),
contentAlignment = Alignment.Center,
) {
if (idx < candidates.size) {
@@ -346,24 +367,29 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
)
} else {
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
// would read as "a game whose cover failed to load".
Text(
game.title,
if (game.isLauncher) game.storeLabel else game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.75f),
color = ink.fg(0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
// Store badge, top-start.
// Store badge, top-start — brand-filled for a launcher entry (design D4).
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
Text(
if (game.isCustom) "Custom" else "Steam",
game.storeLabel,
style = MaterialTheme.typography.labelSmall,
color = Color.White,
color = ink.fg,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.5f))
.background(
if (game.isLauncher) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.5f),
)
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
@@ -5,92 +5,118 @@ import org.junit.Assert.assertTrue
import org.junit.Test
// The console UI's background palettes. These assertions are the CONTRACT the Rust
// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the
// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value
// names the same colour family on every client.
// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
// on every client.
class GamepadPaletteTest {
/** The brightest pool of the field — the colour a palette is judged by. */
private val violetPool = Triple(0.49, 0.39, 0.95)
/**
* The brand default must be the IDENTITY transform. Every existing install already sees the
* shipped violet backdrop, and a palette table that quietly restyled it would be a regression
* dressed as a feature.
*/
@Test
fun violetIsTheUntouchedShippedField() {
val violet = GamepadPalette.named("violet")
assertEquals("violet", GamepadPalette.ALL.first().id)
assertTrue(violet.isIdentity)
assertEquals(violetPool, violet.tint(violetPool))
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
assertEquals("violet", GamepadPalette.named("").id)
private fun luma(c: Triple<Double, Double, Double>) =
0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third
/** Hue angle in degrees, or null for something too grey to have one. */
private fun hue(c: Triple<Double, Double, Double>): Double? {
val (r, g, b) = c
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val d = max - min
if (d < 0.04) return null
val h = when (max) {
r -> 60.0 * (((g - b) / d) % 6.0)
g -> 60.0 * ((b - r) / d + 2.0)
else -> 60.0 * ((r - g) / d + 4.0)
}
return (h + 360.0) % 360.0
}
/** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */
/** Ids, order and the light/dark split are the cross-client contract. */
@Test
fun tableMatchesTheOtherClients() {
assertEquals(
listOf("violet", "tide", "forest", "ember", "rose", "graphite"),
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
assertEquals(
listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"),
GamepadPalette.ALL.map { it.name },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
assertEquals("violet", GamepadPalette.named("").id)
// The brand default keeps the shipped field rather than a generated ramp.
assertTrue(GamepadPalette.named("violet").stops.isEmpty())
}
/**
* A rotation moves the hue while roughly holding luminance, and the saturation scale collapses
* toward grey — the same four checks the Rust and Swift tests make.
* A palette must read as SEVERAL hues, not one hue at several brightnesses — that was exactly
* the complaint about the hue-rotation model this replaced.
*/
@Test
fun tintRotatesHueAndScalesSaturation() {
assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second)
// +105° (Ember) turns the blue-dominant pool red-dominant…
val ember = GamepadPalette.named("ember").tint(violetPool)
assertTrue("$ember should be warm", ember.first > ember.third)
// …−130° (Forest) turns it green-dominant…
val forest = GamepadPalette.named("forest").tint(violetPool)
assertTrue("$forest", forest.second > forest.first && forest.second > forest.third)
// …and 70° (Tide) lands on a cyan whose green and blue both beat red.
val tide = GamepadPalette.named("tide").tint(violetPool)
assertTrue("$tide", tide.second > tide.first && tide.third > tide.first)
// Graphite's saturation scale leaves the channels nearly equal…
val grey = GamepadPalette.named("graphite").tint(violetPool)
val channels = listOf(grey.first, grey.second, grey.third)
assertTrue("$grey", channels.max() - channels.min() < 0.08)
// …at about the source's luminance (it desaturates, it doesn't dim).
val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third
assertEquals(luma, grey.second, 0.05)
}
/**
* Every palette stays in gamut on every colour the field is built from — an out-of-range
* channel would clamp differently on each platform's rasteriser.
*/
@Test
fun everyPaletteStaysInGamut() {
val field = listOf(
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74),
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95), Triple(0.28, 0.31, 0.84),
Triple(0.16, 0.26, 0.64), Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70), Triple(0.22, 0.18, 0.54),
Triple(0.24, 0.20, 0.58),
)
for (palette in GamepadPalette.ALL) {
for (c in field) {
val t = palette.tint(c)
for (v in listOf(t.first, t.second, t.third)) {
assertTrue("${palette.id} $c$t", v in 0.0..1.0)
fun everyPaletteIsMultiTone() {
for (p in GamepadPalette.ALL) {
val stops = p.stops.ifEmpty { continue }
val hues = stops.mapNotNull { hue(it) }
assertTrue("${p.id}: too few coloured stops", hues.size >= 3)
var spread = 0.0
for (a in hues) {
for (b in hues) {
val d = Math.abs(a - b) % 360.0
spread = maxOf(spread, minOf(d, 360.0 - d))
}
}
// Graphite and Opal are deliberately near-neutral; the rest must travel.
val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0
assertTrue("${p.id} spans only $spread° of hue", spread >= floor)
}
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
for (p in GamepadPalette.ALL) {
if (p.light) {
assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6)
assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45)
} else {
assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2)
assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25)
}
}
}
/** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */
@Test
fun rampInterpolatesBetweenStops() {
val stops = listOf(
Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0),
)
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0))
assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5))
assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9)
// Out of range clamps rather than throwing.
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0))
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5))
}
/** The ink a palette calls for: white on a dark field, near-black on a pale one. */
@Test
fun inkFollowsTheField() {
val dark = GamepadInk.of(GamepadPalette.named("violet"))
assertTrue(!dark.isLight)
assertEquals(1f, dark.fg.red, 1e-6f)
assertEquals(1f, dark.shadeScale, 1e-6f)
val light = GamepadInk.of(GamepadPalette.named("holo"))
assertTrue(light.isLight)
assertTrue("pale fields need dark ink", light.fg.red < 0.3f)
// A pale field's scrims must pull far less, or they bleach the gradient.
assertTrue(light.shadeScale < 0.5f)
}
/**
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
@@ -109,6 +109,11 @@ class ScreenshotTest {
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
@Test
fun consoleSettingsLight() =
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -31,7 +31,12 @@ import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.LocalGamepadInk
import io.unom.punktfunk.LocalGamepadPalette
import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsCategory
@@ -415,5 +420,17 @@ internal fun ConnectConsoleScene() =
* a layout regression would eat first.
*/
@Composable
internal fun ConsoleSettingsScene() =
GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
// The scene calls the screen directly, so it has to publish the palette locals `App` would
// normally provide — without them a light palette would render with the default DARK ink and
// the shot would silently prove nothing.
val palette = GamepadPalette.named(paletteId)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
GamepadSettingsScreen(
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
)
}
}
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
}
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
/**
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
*
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
* game rather than break the decode (design D4).
*/
data class GameEntry(
val id: String,
val store: String,
val title: String,
val art: Artwork,
val role: String? = null,
) {
val isCustom: Boolean get() = store == "custom"
/** Whether this entry opens a launcher rather than a game. */
val isLauncher: Boolean get() = role == "launcher"
/**
* Display name for the store badge — the same table the other clients use
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
* entry, which a Lutris or GOG title made a lie.
*/
val storeLabel: String get() = when (store) {
"steam" -> "Steam"
"custom" -> "Custom"
"heroic" -> "Heroic"
"lutris" -> "Lutris"
"epic" -> "Epic"
"gog" -> "GOG"
"xbox" -> "Xbox"
else -> "Game"
}
}
/**
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
* Applied once where the library is fetched, so no screen has to remember the rule — and a library
* without launcher entries comes back untouched.
*/
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
val launchers = filter { it.isLauncher }
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
}
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
@@ -108,10 +150,11 @@ object LibraryClient {
header = resolveArt(str(art, "header"), base),
hero = resolveArt(str(art, "hero"), base),
),
role = str(o, "role"),
),
)
}
return out
return out.launchersFirst()
}
/** A present, non-null, non-blank JSON string field, else null. */
@@ -536,6 +536,9 @@ struct ContentView: View {
waker: waker,
gamepadUI: gamepadUIActive,
onCancelConnect: { model.disconnect() })
// The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so
// it publishes the palette's ink itself rather than inheriting it.
.gamepadPaletteInk()
}
}
@@ -47,12 +47,16 @@ struct ConnectOverlay: View {
return nil
}
@Environment(\.gamepadInk) private var ink
var body: some View {
if let phase {
ZStack {
if gamepadUI {
// Console: an opaque, living aurora over everything.
Color.black.ignoresSafeArea()
// Console: an opaque, living aurora over everything, in the chosen palette.
// The takeover's own text rides `ink`, so a pale palette flips it here too
// without that this is the one console screen that stays white-on-white.
ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
@@ -70,7 +74,8 @@ struct ConnectOverlay: View {
.padding(40)
}
}
.environment(\.colorScheme, .dark)
// The console takeover follows the palette; the default UI's modal stays dark.
.environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
@@ -12,6 +12,7 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
let onAdd: (StoredHost) -> Void
@@ -47,12 +48,12 @@ struct GamepadAddHostView: View {
VStack(spacing: 4) {
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.center)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
}
@@ -73,6 +74,9 @@ struct GamepadAddHostView: View {
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
.background { GamepadFormBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// A port can't exceed 5 digits cap while typing so the row can't grow absurd.
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
@@ -143,7 +147,7 @@ struct GamepadAddHostView: View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
@@ -180,22 +184,22 @@ struct GamepadAddHostView: View {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.foregroundStyle(canAdd ? ink.accent : ink.fg(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(Color.brand)
.fill(ink.accent)
.frame(width: 2, height: m.labelFont + 2)
}
}
@@ -206,12 +210,12 @@ struct GamepadAddHostView: View {
// takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
@@ -89,6 +89,7 @@ struct GamepadHint: Identifiable {
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
@@ -108,17 +109,17 @@ struct GamepadHintBar: View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
.foregroundStyle(ink.fg(0.85))
.padding(Self.pad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
}
}
@@ -145,6 +146,7 @@ struct GamepadHintBar: View {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// Quiet the field for a form screen (see the type comment).
var calm = false
@@ -171,36 +173,41 @@ struct GamepadScreenBackground: View {
/// vignette, and the title/hints legibility scrim in that order, matching the console
/// shader's `composite` so the two platforms' backdrops stay the same picture.
private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
ZStack {
Color.black
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark
// field's strength bleaches the chroma straight out of the gradient, so a pale palette
// gets under half the same `u_scrim.a` the console shader carries.
let scrim: Color = palette.light ? ink.fg : .black
let strength = palette.light ? 0.45 : 1.0
return ZStack {
Self.color(palette.ground)
colorField(at: t, palette: palette)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + corner·0.4: over black, `.opacity` IS the multiply
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
.opacity(calm ? 0.6 : 1)
if calm {
// and a plusLighter wash of the palette's own corner colour IS the add. Chosen so
// a corner lands exactly where it was and the bright pools come down to meet it.
Self.color(palette.tint(Self.cornerRGB))
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
Self.color(palette.ground)
.opacity(0.4)
.blendMode(.plusLighter)
}
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
// Soft (extends past the frame) so the corners deepen rather than crush to black.
// Halved under calm: a launcher's cards sit in the pooled centre, but a form screen's
// rows run out toward the edges, where crushing to black just eats them.
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
// crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
// screen's rows run out toward the edges, where crushing them just eats the list.
EllipticalGradient(
colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)],
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
// darkens the aurora itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one now.
// works on the field itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one.
LinearGradient(
stops: [
.init(color: .black.opacity(0.38), location: 0),
.init(color: .black.opacity(0.06), location: 0.32),
.init(color: .black.opacity(0.08), location: 0.68),
.init(color: .black.opacity(0.40), location: 1),
.init(color: scrim.opacity(0.38 * strength), location: 0),
.init(color: scrim.opacity(0.06 * strength), location: 0.32),
.init(color: scrim.opacity(0.08 * strength), location: 0.68),
.init(color: scrim.opacity(0.40 * strength), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
@@ -211,7 +218,7 @@ struct GamepadScreenBackground: View {
MeshGradient(
width: 4, height: 4,
points: Self.meshPoints(at: t),
colors: Self.meshColors(palette),
colors: palette.meshColors.map(Self.color),
smoothsColors: true)
} else {
LegacyBlobField(t: t, palette: palette)
@@ -224,28 +231,6 @@ struct GamepadScreenBackground: View {
Color(red: c.x, green: c.y, blue: c.z)
}
/// The corner colour the four pinned corners AND the calm lift's base.
static let cornerRGB = SIMD3(0.075, 0.060, 0.160)
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
/// mid-tone violets, and the four interior points hold the bright brand family a violet and a
/// blue-violet up top, a magenta-violet and a violet below so warm pools on the left, cool on
/// the right, and the silk shifts temperature as those interior points drift. A palette rotates
/// the whole grid; `violet` is the identity, so this array IS what the default draws.
private static let baseMeshRGB: [SIMD3<Double>] = [
cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB,
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB,
]
/// `baseMeshRGB` under a palette. Recomputed per frame rather than cached sixteen `tint`
/// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be
/// mutable global state on a type SwiftUI is free to evaluate off the main actor.
private static func meshColors(_ palette: GamepadPalette) -> [Color] {
baseMeshRGB.map { color(palette.tint($0)) }
}
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
/// always fills edge-to-edge a drifting edge point would shrink the mesh and expose the black
/// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
@@ -280,9 +265,9 @@ private struct LegacyBlobField: View {
let palette: GamepadPalette
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
/// (rad/s periods of 3090 s), and a radius that slowly breathes.
/// (rad/s periods of 3090 s), and a radius that slowly breathes. The COLOUR comes from the
/// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too.
private struct Blob {
let rgb: SIMD3<Double>
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
@@ -293,20 +278,16 @@ private struct LegacyBlobField: View {
}
private static let blobs: [Blob] = [
Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
speed: (0.111, 0.083), phase: (0.0, 1.9),
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
Blob(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
speed: (0.071, 0.096), phase: (2.4, 0.7),
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
Blob(rgb: SIMD3(0.62, 0.30, 0.80), // plum
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
speed: (0.089, 0.067), phase: (4.1, 3.2),
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
Blob(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
Blob(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
speed: (0.059, 0.104), phase: (1.2, 5.0),
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
]
@@ -316,19 +297,21 @@ private struct LegacyBlobField: View {
let side = max(geo.size.width, geo.size.height)
ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], in: geo.size, side: side)
blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side)
}
}
.drawingGroup()
}
}
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
private func blobView(
_ blob: Blob, tone: SIMD3<Double>, in size: CGSize, side: CGFloat
) -> some View {
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
let color = GamepadScreenBackground.color(palette.tint(blob.rgb))
let color = GamepadScreenBackground.color(tone)
return Circle()
.fill(RadialGradient(
colors: [color, color.opacity(0)],
@@ -336,7 +319,9 @@ private struct LegacyBlobField: View {
.frame(width: r, height: r)
.position(x: x * size.width, y: y * size.height)
.opacity(blob.opacity)
.blendMode(.plusLighter)
// Additive only works over a DARK ground; over a pale one every blob saturates to
// white and the field turns grey. Pale palettes tint instead.
.blendMode(palette.light ? .normal : .plusLighter)
}
}
@@ -346,15 +331,17 @@ private struct LegacyBlobField: View {
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// These trays always sit on the dark console UI; force dark so the material frosts dark
// (white text stays legible) regardless of the system appearance.
.environment(\.colorScheme, .dark)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
.mask {
LinearGradient(
@@ -404,6 +391,7 @@ struct ConsoleBareButtonStyle: ButtonStyle {
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
@Environment(\.gamepadInk) private var ink
let controller: GamepadManager.DiscoveredController
// Legible from the couch on tvOS, quiet in hand elsewhere.
@@ -428,15 +416,15 @@ struct ControllerStatusChip: View {
Image(systemName: batterySymbol(level))
.font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7)))
}
}
.font(.geist(Self.font, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.7))
.foregroundStyle(ink.fg(0.7))
.padding(.horizontal, Self.hPad)
.padding(.vertical, Self.vPad)
.background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
.background(Capsule().fill(ink.fg(0.08)))
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
}
private func batterySymbol(_ level: Float) -> String {
@@ -64,6 +64,7 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@@ -115,6 +116,9 @@ struct GamepadHomeView: View {
.padding(.top, compact ? 4 : 8)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
@@ -186,7 +190,7 @@ struct GamepadHomeView: View {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
@@ -355,6 +359,7 @@ struct GamepadHomeView: View {
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
@Environment(\.gamepadInk) private var ink
let tile: HomeTile
let size: CGSize
@@ -394,7 +399,7 @@ private struct GamepadHostTile: View {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
.foregroundStyle(ink.fg(0.5))
}
if tile.isOnline {
Circle()
@@ -407,7 +412,7 @@ private struct GamepadHostTile: View {
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.7)
if let profile = tile.profile {
@@ -417,7 +422,7 @@ private struct GamepadHostTile: View {
}
Text(tile.subtitle)
.font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.foregroundStyle(ink.fg(0.55))
.lineLimit(1)
.padding(.top, 2)
}
@@ -427,12 +432,12 @@ private struct GamepadHostTile: View {
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
tint: tile.filled ? ink.accent(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)],
colors: [ink.fg(0.22), ink.fg(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
@@ -444,15 +449,15 @@ private struct GamepadHostTile: View {
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [Color.brand, Color.brand.opacity(0.68)],
colors: [ink.accent, ink.accent(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(Color.brand.opacity(0.16)))
: AnyShapeStyle(ink.accent(0.16)))
if tile.isConnecting {
ProgressView().tint(.white)
ProgressView().tint(ink.fg)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand)
.foregroundStyle(ink.accent)
} else if let mark = osIconImage(for: tile.osChain) {
// The OS mark stands in for the initial (template asset tints like the text it
// replaces), and carries the label, since nothing else on the tile names the OS.
@@ -460,18 +465,18 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? .white : Color.brand)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay {
if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
shape.strokeBorder(ink.accent(0.5), lineWidth: 1)
}
}
}
@@ -0,0 +1,91 @@
// 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) }
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"))
}
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 to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
}
private struct GamepadInkModifier: ViewModifier {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
}
}
#endif
@@ -14,6 +14,7 @@ import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadKeyboard: View {
@Environment(\.gamepadInk) private var ink
@Binding var text: String
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
var allowed: CharacterSet?
@@ -79,7 +80,7 @@ struct GamepadKeyboard: View {
}
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
@@ -110,11 +111,11 @@ struct GamepadKeyboard: View {
.font(.geist(15, .semibold, relativeTo: .callout))
}
}
.foregroundStyle(focused ? Color.black : .white)
.foregroundStyle(focused ? Color.black : ink.fg)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
.fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08)))
}
.animation(.smooth(duration: 0.12), value: focused)
.contentShape(Rectangle())
@@ -19,6 +19,7 @@ import SwiftUI
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
let games: [GameEntry]
let imageSession: URLSession?
var onLaunch: ((String) -> Void)?
@@ -46,18 +47,24 @@ struct LibraryCoverflowView: View {
.padding(.vertical, compact ? 6 : 10)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
}
@ViewBuilder private func content(for size: CGSize) -> some View {
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
// safe-area inset, already out of this budget) capped so it never dwarfs a large iPad and
// clamped by width on a narrow screen.
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
let coverWidth = coverHeight * 2 / 3
VStack(spacing: 0) {
Spacer(minLength: 4)
if showsGroupHeading {
groupHeading.padding(.bottom, 6)
}
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
detailPanel
.padding(.top, 12)
@@ -89,10 +96,12 @@ struct LibraryCoverflowView: View {
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.scrollTransition { content, phase in
@@ -112,21 +121,42 @@ struct LibraryCoverflowView: View {
}
}
/// Does this library have both groups? Only then does the heading earn its row a
/// launcher-less library gets exactly the layout it had before design D4.
private var showsGroupHeading: Bool {
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
}
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
/// changes as the selection crosses the boundary the launcher entries lead the strip.
private var groupHeading: some View {
let selected = games.first { $0.id == selection }
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.4)
.foregroundStyle(ink.fg(0.45))
}
/// The centered title + store tag empty (not hidden) so the layout doesn't jump.
@ViewBuilder private var detailPanel: some View {
let game = games.first { $0.id == selection }
VStack(spacing: 6) {
Text(game?.title ?? " ")
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.multilineTextAlignment(.center)
if let game {
Text(game.isCustom ? "CUSTOM" : "STEAM")
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(.white.opacity(0.5))
// main's richer store label, in the palette's ink.
Text(
game.isLauncher
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
)
.font(.geist(11, .semibold, relativeTo: .caption2))
.tracking(1.2)
.foregroundStyle(ink.fg(0.5))
}
}
.frame(maxWidth: .infinity)
@@ -139,7 +169,10 @@ struct LibraryCoverflowView: View {
private var hints: [GamepadHint] {
var hints: [GamepadHint] = []
if onLaunch != nil {
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
// You *open* a launcher and *launch* a game the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
return hints
@@ -80,21 +80,47 @@ struct LibraryView: View {
}
private var grid: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(games) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
// renders exactly as it did before.
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
}
.padding()
}
}
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
.buttonStyle(.plain)
} else {
GameCard(game: game, imageSession: imageSession)
}
}
}
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
.tracking(1.1)
.foregroundStyle(.secondary)
}
private var columns: [GridItem] {
#if os(tvOS)
let minW: CGFloat = 220
@@ -152,12 +178,15 @@ struct LibraryView: View {
return
}
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
// the gamepad coverflow both inherit the D4 ordering.
games = try await LibraryClient.fetch(
address: current.address,
port: current.effectiveMgmtPort,
certPEM: identity.certPEM,
keyPEM: identity.keyPEM,
hostFingerprint: current.pinnedSHA256)
hostFingerprint: current.pinnedSHA256
).launchersFirst
imageSession?.finishTasksAndInvalidate()
imageSession = try LibraryImageLoader.session(
address: current.address,
@@ -185,7 +214,9 @@ private struct GameCard: View {
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
Text(game.title)
.font(.geist(12, relativeTo: .caption))
.lineLimit(2)
@@ -12,14 +12,21 @@ import AppKit
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
struct StoreBadge: View {
let isCustom: Bool
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
let label: String
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
/// without reading the title.
var isLauncher: Bool = false
var body: some View {
Text(isCustom ? "Custom" : "Steam")
Text(label)
.font(.geist(11, .semibold, relativeTo: .caption2))
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.background(
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
in: Capsule())
.padding(6)
}
}
@@ -45,6 +45,7 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
}
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
@@ -136,7 +137,7 @@ struct GamepadSettingsView: View {
VStack(spacing: compact ? 4 : 8) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
// The picker is one layer deeper its rows aren't sections of anything, so the
@@ -151,7 +152,7 @@ struct GamepadSettingsView: View {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: hints)
@@ -168,6 +169,9 @@ struct GamepadSettingsView: View {
// colour and luminance to lens without the launcher's contrast, and the palette setting
// applies here too, so this screen previews the row you're stepping.
.background { GamepadFormBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear {
gamepads.refresh()
gamepads.startDiscovery()
@@ -220,7 +224,7 @@ struct GamepadSettingsView: View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.foregroundStyle(selected ? .white : .white.opacity(0.55))
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
.padding(.horizontal, 13)
.padding(.vertical, 7)
.background {
@@ -228,7 +232,7 @@ struct GamepadSettingsView: View {
// in and out the highlight travels the way the press did.
if selected {
Capsule()
.fill(Color.brand.opacity(0.85))
.fill(ink.accent(0.85))
.matchedGeometryEffect(id: "tab", in: tabHighlight)
}
}
@@ -276,7 +280,7 @@ struct GamepadSettingsView: View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
@@ -347,18 +351,18 @@ struct GamepadSettingsView: View {
HStack(spacing: 14) {
Image(systemName: row.icon)
.font(.system(size: m.iconFont))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.foregroundStyle(focused ? ink.accent : ink.fg(0.55))
.frame(width: m.iconWidth)
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white)
.foregroundStyle(ink.fg)
.lineLimit(1)
Spacer(minLength: 12)
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -369,7 +373,7 @@ struct GamepadSettingsView: View {
ZStack {
Text(row.value)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
.lineLimit(1)
.id(row.value)
.transition(.asymmetric(
@@ -380,7 +384,7 @@ struct GamepadSettingsView: View {
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
@@ -391,11 +395,11 @@ struct GamepadSettingsView: View {
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil,
tint: focused ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
@@ -80,6 +80,12 @@ 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 }
func body(content: Content) -> some View {
#if os(tvOS)
@@ -89,16 +95,16 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
.environment(\.colorScheme, scheme)
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape)
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
}
#endif
}
@@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
public var id: String
public var store: String // "steam" | "custom"
public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
public var title: String
public var art: Artwork
public var launch: LaunchSpec?
/// `"game"` (the default, and what an older host omits) or `"launcher"` an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
public var role: String?
public var isCustom: Bool { store == "custom" }
/// Whether this entry opens a launcher rather than a game.
public var isLauncher: Bool { role == "launcher" }
/// Display name for the store badge the same table the Rust clients use
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
/// every non-custom entry, which a Lutris or GOG title made a lie.
public var storeLabel: String {
switch store {
case "steam": return "Steam"
case "custom": return "Custom"
case "heroic": return "Heroic"
case "lutris": return "Lutris"
case "epic": return "Epic"
case "gog": return "GOG"
case "xbox": return "Xbox"
default: return "Game"
}
}
}
public extension Array where Element == GameEntry {
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
/// group. Applied once where the library is fetched, so no individual view has to remember
/// the rule and a library without launcher entries comes back untouched.
var launchersFirst: [GameEntry] {
let launchers = filter(\.isLauncher)
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
@@ -1,14 +1,18 @@
// The gamepad UI's background colour families.
// The gamepad UI's background colour families, and the ink each one calls for.
//
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its
// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is
// exactly the shipped look `violet` is the identity transform.
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
// 4×4 mesh samples that ramp diagonally with a per-cell offset (`cellRamp`), so neighbouring
// cells land on different parts of it and the colours pool and swirl the way a real gradient
// poster does; the mesh's existing control-point drift then moves those pools around. An earlier
// version rotated ONE field's hue per palette, which is why every non-default palette read flat.
//
// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette`
// setting names the same colour family on every client. Keep the three copies in step: a palette
// added here without the others is a value the other clients will silently render as Violet.
// A palette also owns the UI sitting on it: `accent` is the focus wash / selected pill / switch
// colour, and `light` flips the ink so a pale field gets dark text instead of white.
//
// The table, `ramp` and `cellRamp` are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so one `ui_palette` value is
// one look on every client. Keep the three copies in step: a palette added here without the
// others is a value the other clients silently render as Violet.
//
// It lives in PunktfunkShared rather than next to the views because that is the target the tests
// can reach the arithmetic below is the part that has to agree across three languages.
@@ -21,20 +25,121 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
public let id: String
/// What the settings row shows.
public let name: String
/// Hue rotation about the grey axis, degrees positive runs red green blue.
public let hueDegrees: Double
/// Saturation scale about luminance; 1 keeps the source saturation.
public let saturation: Double
/// The colour ramp, dark end first. Empty = use `violetMesh` verbatim (the brand default,
/// kept bit-identical to what every install already sees).
public let stops: [SIMD3<Double>]
/// The field's ground what the corners settle onto and what the calm mix lifts toward.
public let ground: SIMD3<Double>
/// The UI accent: focus wash, selected tab pill, switch track, caret.
public let accent: SIMD3<Double>
/// A pale field: the UI flips to dark ink and the legibility scrims go white.
public let light: Bool
/// The six shipped palettes, in cycling order: the brand violet, then cool warm, then the
/// neutral.
/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal
/// `0.5·(x + y)` top-left is the ramp's dark end, bottom-right its bright one and the
/// per-cell nudges break the banding a pure diagonal would give, so hues pool instead of
/// striping.
static let cellRamp: [Double] = [
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
]
/// The brand default's 16 mesh colours, row-major 4×4: dark-violet corners sink the frame,
/// the edges carry mid-tone violets, and the interior holds the bright brand family.
public static let violetMesh: [SIMD3<Double>] = {
let corner = SIMD3(0.075, 0.060, 0.160)
return [
corner, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), corner,
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
corner, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), corner,
]
}()
/// The brand default's blob ramp the four colours the pre-18/15 legacy field used, kept so
/// `violet` is unchanged on older OSes too.
static let violetBlobs: [SIMD3<Double>] = [
SIMD3(0.53, 0.47, 0.96), SIMD3(0.24, 0.20, 0.72), SIMD3(0.62, 0.30, 0.80),
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
]
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
/// ones. Cycling order runs dark light, so stepping the row walks the whole range one way.
public static let all: [GamepadPalette] = [
GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0),
GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0),
GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9),
GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0),
GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95),
GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12),
// --- dark fields (white ink) ---
GamepadPalette(
id: "violet", name: "Violet", stops: [],
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
stops: [SIMD3(0.07, 0.05, 0.20), SIMD3(0.26, 0.14, 0.54), SIMD3(0.52, 0.20, 0.72),
SIMD3(0.82, 0.26, 0.62), SIMD3(0.98, 0.46, 0.68)],
ground: SIMD3(0.055, 0.040, 0.135), accent: SIMD3(0.95, 0.42, 0.72), light: false),
GamepadPalette(
// Ink-blue water: teal cerulean a violet undertow.
id: "abyss", name: "Abyss",
stops: [SIMD3(0.02, 0.10, 0.17), SIMD3(0.04, 0.28, 0.42), SIMD3(0.07, 0.46, 0.63),
SIMD3(0.16, 0.38, 0.78), SIMD3(0.26, 0.22, 0.58)],
ground: SIMD3(0.018, 0.070, 0.130), accent: SIMD3(0.26, 0.76, 0.92), light: false),
GamepadPalette(
// Banked coals: plum embers crimson burnt orange gold.
id: "ember", name: "Ember",
stops: [SIMD3(0.16, 0.03, 0.10), SIMD3(0.45, 0.06, 0.12), SIMD3(0.72, 0.18, 0.06),
SIMD3(0.90, 0.42, 0.08), SIMD3(0.95, 0.68, 0.18)],
ground: SIMD3(0.090, 0.035, 0.040), accent: SIMD3(0.98, 0.62, 0.26), light: false),
GamepadPalette(
// Forest floor into moss and a lime break.
id: "moss", name: "Moss",
stops: [SIMD3(0.03, 0.11, 0.09), SIMD3(0.06, 0.27, 0.20), SIMD3(0.09, 0.45, 0.31),
SIMD3(0.28, 0.61, 0.28), SIMD3(0.58, 0.77, 0.31)],
ground: SIMD3(0.025, 0.085, 0.070), accent: SIMD3(0.48, 0.86, 0.46), light: false),
GamepadPalette(
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone.
id: "graphite", name: "Graphite",
stops: [SIMD3(0.06, 0.07, 0.11), SIMD3(0.15, 0.18, 0.25), SIMD3(0.30, 0.31, 0.35),
SIMD3(0.45, 0.42, 0.38), SIMD3(0.60, 0.56, 0.49)],
ground: SIMD3(0.055, 0.055, 0.070), accent: SIMD3(0.78, 0.80, 0.86), light: false),
// --- pale fields (dark ink) ---
GamepadPalette(
// The holographic foil: rose lilac periwinkle aqua, with a white bloom.
id: "holo", name: "Holo",
stops: [SIMD3(0.99, 0.72, 0.90), SIMD3(0.80, 0.60, 0.98), SIMD3(0.58, 0.62, 0.99),
SIMD3(0.55, 0.86, 0.98), SIMD3(0.94, 0.98, 1.00)],
ground: SIMD3(0.96, 0.92, 0.99), accent: SIMD3(0.42, 0.28, 0.86), light: true),
GamepadPalette(
// The poster sunset: periwinkle magenta scarlet tangerine gold.
id: "sunset", name: "Sunset",
stops: [SIMD3(0.55, 0.45, 0.92), SIMD3(0.86, 0.31, 0.66), SIMD3(0.97, 0.26, 0.34),
SIMD3(0.99, 0.51, 0.18), SIMD3(1.00, 0.80, 0.22)],
ground: SIMD3(0.98, 0.74, 0.34), accent: SIMD3(0.64, 0.13, 0.44), light: true),
GamepadPalette(
// Peach into blush and lilac the softest of the set.
id: "bloom", name: "Bloom",
stops: [SIMD3(1.00, 0.86, 0.72), SIMD3(0.99, 0.73, 0.79), SIMD3(0.95, 0.65, 0.89),
SIMD3(0.82, 0.68, 0.96), SIMD3(0.73, 0.79, 0.99)],
ground: SIMD3(0.99, 0.90, 0.89), accent: SIMD3(0.72, 0.24, 0.55), light: true),
GamepadPalette(
// First light: pale gold coral lilac.
id: "dawn", name: "Dawn",
stops: [SIMD3(1.00, 0.92, 0.70), SIMD3(1.00, 0.80, 0.62), SIMD3(0.99, 0.66, 0.62),
SIMD3(0.90, 0.62, 0.78), SIMD3(0.77, 0.69, 0.95)],
ground: SIMD3(1.00, 0.93, 0.82), accent: SIMD3(0.82, 0.33, 0.28), light: true),
GamepadPalette(
// Sea glass: mint aqua a pale sky.
id: "mint", name: "Mint",
stops: [SIMD3(0.82, 0.98, 0.90), SIMD3(0.62, 0.94, 0.88), SIMD3(0.55, 0.88, 0.95),
SIMD3(0.63, 0.82, 0.99), SIMD3(0.82, 0.87, 1.00)],
ground: SIMD3(0.90, 0.98, 0.96), accent: SIMD3(0.04, 0.42, 0.40), light: true),
GamepadPalette(
// Near-white, but iridescent rather than flat rose, sky, mint and cream in turn.
id: "opal", name: "Opal",
stops: [SIMD3(0.98, 0.92, 0.96), SIMD3(0.87, 0.93, 0.99), SIMD3(0.91, 0.99, 0.95),
SIMD3(0.99, 0.96, 0.88), SIMD3(0.94, 0.90, 0.99)],
ground: SIMD3(0.97, 0.96, 0.99), accent: SIMD3(0.36, 0.32, 0.44), light: true),
]
/// The palette stored under `id`, falling back to the brand default an unknown name is a
@@ -43,37 +148,30 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
all.first { $0.id == id } ?? all[0]
}
/// `true` for the identity transform, so the default path can skip the per-colour work.
public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 }
/// Apply this palette to one RGB triple.
public func tint(_ c: SIMD3<Double>) -> SIMD3<Double> {
guard !isIdentity else { return c }
return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation)
/// Sample an ordered colour ramp at `t` [0, 1] (linear between neighbouring stops).
public static func ramp(_ stops: [SIMD3<Double>], _ t: Double) -> SIMD3<Double> {
guard let first = stops.first else { return SIMD3(0, 0, 0) }
guard stops.count > 1 else { return first }
let x = min(max(t, 0), 1) * Double(stops.count - 1)
let i = min(Int(x.rounded(.down)), stops.count - 2)
let f = x - Double(i)
return stops[i] + (stops[i + 1] - stops[i]) * f
}
/// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues the same rotation the field's
/// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about
/// luminance. Clamped, because a large rotation can push a channel out of gamut.
///
/// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's
/// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent
/// doing the arithmetic on the COLOURS keeps the three implementations identical.
public static func tint(
_ c: SIMD3<Double>, hueDegrees: Double, saturation: Double
) -> SIMD3<Double> {
let a = hueDegrees * .pi / 180
let cs = cos(a)
let sn = sin(a)
let invSqrt3 = 1 / 3.0.squareRoot()
let grey = (c.x + c.y + c.z) / 3 * (1 - cs)
// The `sn` term is cross(k, c) with k = (1,1,1)/3.
let rot = SIMD3(
c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey,
c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey,
c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey)
let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z
func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) }
return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z))
/// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim
/// for the brand default.
public var meshColors: [SIMD3<Double>] {
guard !stops.isEmpty else { return Self.violetMesh }
return (0..<16).map { i in
let (x, y) = (Double(i % 4) / 3.0, Double(i / 4) / 3.0)
return Self.ramp(stops, 0.5 * (x + y) + Self.cellRamp[i])
}
}
/// Four drifting blob colours for the pre-18/15 legacy field. Spread across the ramp so it
/// still shows several hues at once.
public var blobColors: [SIMD3<Double>] {
let s = stops.isEmpty ? Self.violetBlobs : stops
return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) }
}
}
@@ -1,81 +1,110 @@
// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust
// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce
// the same ids, the same rotation orientation, the same in-gamut results so one `ui_palette`
// value names the same colour family on every client.
// (`pf-console-ui::library`) and Kotlin (`GamepadPalette.kt`) ports reproduce the same ids in
// the same order, the same light/dark split, the same ramp so one `ui_palette` value is one look
// on every client.
import XCTest
import simd
@testable import PunktfunkShared
final class GamepadPaletteTests: XCTestCase {
/// The brightest interior pool of the mesh field the colour a palette is judged by.
private let violetPool = SIMD3(0.49, 0.39, 0.95)
private func luma(_ c: SIMD3<Double>) -> Double {
0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z
}
/// The brand default must be the IDENTITY transform. Every existing install already sees the
/// shipped violet backdrop, and a palette table that quietly restyled it would be a
/// Hue angle in degrees, or nil for something too grey to have one.
private func hue(_ c: SIMD3<Double>) -> Double? {
let maxV = max(c.x, c.y, c.z)
let minV = min(c.x, c.y, c.z)
let d = maxV - minV
guard d >= 0.04 else { return nil }
let h: Double
if maxV == c.x {
h = 60 * (((c.y - c.z) / d).truncatingRemainder(dividingBy: 6))
} else if maxV == c.y {
h = 60 * ((c.z - c.x) / d + 2)
} else {
h = 60 * ((c.x - c.y) / d + 4)
}
return (h + 360).truncatingRemainder(dividingBy: 360)
}
/// The brand default must still be the SHIPPED field, colour for colour. Every install
/// already sees it, and a palette table that quietly restyled the default would be a
/// regression dressed as a feature.
func testVioletIsTheUntouchedShippedField() {
let violet = GamepadPalette.named("violet")
XCTAssertEqual(GamepadPalette.all.first?.id, "violet")
XCTAssertTrue(violet.isIdentity)
XCTAssertEqual(violet.tint(violetPool), violetPool)
XCTAssertTrue(violet.stops.isEmpty, "the default is the explicit grid")
XCTAssertEqual(violet.meshColors, GamepadPalette.violetMesh)
// An unknown name is a newer client's palette, not an error.
XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet")
XCTAssertEqual(GamepadPalette.named("").id, "violet")
}
/// The ids and their order are the cross-client contract (the strip order, and the order
/// L1/R1 and A cycle through).
/// Ids, order and the light/dark split are the cross-client contract.
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "tide", "forest", "ember", "rose", "graphite"])
XCTAssertEqual(
GamepadPalette.all.map(\.name),
["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"])
["violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let firstLight = GamepadPalette.all.firstIndex { $0.light }
XCTAssertEqual(firstLight, 6)
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
}
/// A rotation moves the hue while roughly holding luminance, and the saturation scale
/// collapses toward grey the same four checks the Rust test makes.
func testTintRotatesHueAndScalesSaturation() {
XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant")
// +105° (Ember) turns the blue-dominant pool red-dominant
let ember = GamepadPalette.named("ember").tint(violetPool)
XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm")
// 130° (Forest) turns it green-dominant
let forest = GamepadPalette.named("forest").tint(violetPool)
XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)")
// and 70° (Tide) lands on a cyan whose green and blue both beat red.
let tide = GamepadPalette.named("tide").tint(violetPool)
XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)")
// Graphite's saturation scale leaves the channels nearly equal
let grey = GamepadPalette.named("graphite").tint(violetPool)
let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z)
XCTAssertLessThan(spread, 0.08, "\(grey)")
// at about the source's luminance (it desaturates, it doesn't dim).
let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z
XCTAssertEqual(grey.y, luma, accuracy: 0.05)
}
/// Every palette stays in gamut on every colour the field is built from an out-of-range
/// channel would clamp differently on each platform's rasteriser.
func testEveryPaletteStaysInGamut() {
let field: [SIMD3<Double>] = [
SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74),
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84),
SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75),
SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54),
SIMD3(0.24, 0.20, 0.58),
]
for palette in GamepadPalette.all {
for c in field {
let t = palette.tint(c)
for v in [t.x, t.y, t.z] {
XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c)\(t)")
/// A palette must read as SEVERAL hues, not one hue at several brightnesses that was
/// exactly the complaint about the hue-rotation model this replaced.
func testEveryPaletteIsMultiTone() {
for p in GamepadPalette.all {
let hues = p.meshColors.compactMap(hue)
XCTAssertGreaterThanOrEqual(hues.count, 8, "\(p.id): too few coloured cells")
var spread = 0.0
for a in hues {
for b in hues {
let d = abs(a - b).truncatingRemainder(dividingBy: 360)
spread = max(spread, min(d, 360 - d))
}
}
// Graphite and Opal are deliberately near-neutral; the rest must travel.
let floor = (p.id == "graphite" || p.id == "opal") ? 20.0 : 45.0
XCTAssertGreaterThanOrEqual(spread, floor, "\(p.id) spans only \(spread)° of hue")
}
}
/// Every colour stays in gamut, and a pale palette really is pale its ink flips, so a
/// mislabelled one would put dark text on a dark field.
func testPalettesAreInGamutAndHonestAboutLightness() {
for p in GamepadPalette.all {
for c in p.meshColors + p.blobColors {
for v in [c.x, c.y, c.z] {
XCTAssertTrue((0...1).contains(v), "\(p.id) \(c)")
}
}
let mean = p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count)
if p.light {
XCTAssertGreaterThan(mean, 0.5, "\(p.id) is flagged light")
XCTAssertGreaterThan(luma(p.ground), 0.6, "\(p.id)'s ground is dark")
XCTAssertLessThan(luma(p.accent), 0.45, "\(p.id)'s accent is too pale")
} else {
XCTAssertLessThan(mean, 0.45, "\(p.id) is flagged dark")
XCTAssertLessThan(luma(p.ground), 0.2, "\(p.id)'s ground is light")
XCTAssertGreaterThan(luma(p.accent), 0.25, "\(p.id)'s accent is too dark")
}
}
}
/// The ramp is the shared sampling rule the Rust and Kotlin ports reproduce.
func testRampInterpolatesBetweenStops() {
let stops = [SIMD3(0.0, 0.0, 0.0), SIMD3(1.0, 0.0, 0.0), SIMD3(1.0, 1.0, 1.0)]
XCTAssertEqual(GamepadPalette.ramp(stops, 0), SIMD3(0.0, 0.0, 0.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 1), SIMD3(1.0, 1.0, 1.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 0.5), SIMD3(1.0, 0.0, 0.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 0.25).x, 0.5, accuracy: 1e-9)
// Out of range clamps rather than trapping.
XCTAssertEqual(GamepadPalette.ramp(stops, -3), SIMD3(0.0, 0.0, 0.0))
XCTAssertEqual(GamepadPalette.ramp(stops, 9), SIMD3(1.0, 1.0, 1.0))
XCTAssertEqual(GamepadPalette.ramp([], 0.5), SIMD3(0.0, 0.0, 0.0))
}
}
+7
View File
@@ -61,6 +61,13 @@ const CSS: &str = "
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an
art-less one must not read as a game whose cover failed to load: accent face, the launcher
named instead of a title monogram, and an accent badge. */
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
";
/// Everything the shell shares below the component tree.
+10 -2
View File
@@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
});
match crate::library::fetch_games(&addr, port, &identity, pin) {
Ok(games) => {
// A fourth column, appended: `game` or `launcher` (design D4). Appended rather than
// folded into an existing field so anything reading the first three columns is
// untouched.
for g in &games {
println!("{}\t{}\t{}", g.id, g.store, g.title);
let role = if g.is_launcher() { "launcher" } else { "game" };
println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role);
}
let launchers = games.iter().filter(|g| g.is_launcher()).count();
match launchers {
0 => println!("{} game(s)", games.len()),
n => println!("{} game(s), {} launcher(s)", games.len() - n, n),
}
println!("{} game(s)", games.len());
glib::ExitCode::SUCCESS
}
Err(e) => {
+74 -3
View File
@@ -28,6 +28,12 @@ struct State {
req: ConnectRequest,
stack: gtk::Stack,
flow: gtk::FlowBox,
/// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to
/// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none.
launcher_flow: gtk::FlowBox,
launchers_group: gtk::Box,
/// The "Games" heading — only earns its space once a Launchers shelf is above it.
games_heading: gtk::Label,
error_page: adw::StatusPage,
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
art: RefCell<HashMap<String, gdk::Texture>>,
@@ -94,11 +100,44 @@ fn build(
flow.connect_child_activated(|_, child| {
child.activate();
});
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
// groups never interleave and each wraps on its own.
let launcher_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.activate_on_single_click(true)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(6)
.column_spacing(12)
.row_spacing(18)
.valign(gtk::Align::Start)
.build();
launcher_flow.connect_child_activated(|_, child| {
child.activate();
});
let launchers_heading = gtk::Label::new(Some("Launchers"));
launchers_heading.add_css_class("pf-group-heading");
launchers_heading.set_halign(gtk::Align::Start);
launchers_heading.set_margin_bottom(8);
let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0);
launchers_group.append(&launchers_heading);
launchers_group.append(&launcher_flow);
launchers_group.set_margin_bottom(24);
launchers_group.set_visible(false);
let games_heading = gtk::Label::new(Some("Games"));
games_heading.add_css_class("pf-group-heading");
games_heading.set_halign(gtk::Align::Start);
games_heading.set_margin_bottom(8);
games_heading.set_visible(false);
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_margin_top(24);
content.set_margin_bottom(24);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&launchers_group);
content.append(&games_heading);
content.append(&flow);
let clamp = adw::Clamp::builder()
.maximum_size(1100)
@@ -166,6 +205,9 @@ fn build(
req,
stack,
flow,
launcher_flow,
launchers_group,
games_heading,
error_page,
art: RefCell::new(HashMap::new()),
pics: RefCell::new(HashMap::new()),
@@ -224,18 +266,41 @@ fn load(state: &Rc<State>) {
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
fn render(state: &Rc<State>, games: &[GameEntry]) {
state.flow.remove_all();
state.launcher_flow.remove_all();
state.pics.borrow_mut().clear();
for game in games {
// Design D4: launchers never interleave with titles. The host already sorts by title, and
// `partition` is stable, so each group keeps that order.
let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) =
games.iter().partition(|g| g.is_launcher());
for game in &launchers {
state.launcher_flow.append(&game_card(state, game));
}
for game in &titles {
state.flow.append(&game_card(state, game));
}
// A library with no launcher entries looks exactly as it did before this existed.
state.launchers_group.set_visible(!launchers.is_empty());
state
.games_heading
.set_visible(!launchers.is_empty() && !titles.is_empty());
}
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
/// monogram placeholder underneath the async art. Activation starts a session launching
/// this title (silent on a pinned host — the normal trust gate applies).
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
let monogram = gtk::Label::new(Some(&initials(&game.title)));
monogram.add_css_class("pf-poster-monogram");
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
let launcher = game.is_launcher();
let monogram = if launcher {
let l = gtk::Label::new(Some(store_label(&game.store)));
l.add_css_class("pf-poster-launcher-name");
l
} else {
let l = gtk::Label::new(Some(&initials(&game.title)));
l.add_css_class("pf-poster-monogram");
l
};
monogram.set_halign(gtk::Align::Center);
monogram.set_valign(gtk::Align::Center);
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
@@ -252,6 +317,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
let badge = gtk::Label::new(Some(store_label(&game.store)));
badge.add_css_class("pf-pill");
badge.add_css_class("pf-store-badge");
if launcher {
badge.add_css_class("pf-launcher");
}
badge.set_halign(gtk::Align::Start);
badge.set_valign(gtk::Align::Start);
badge.set_margin_start(6);
@@ -262,6 +330,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
poster.add_overlay(&pic);
poster.add_overlay(&badge);
poster.add_css_class("pf-poster");
if launcher {
poster.add_css_class("pf-launcher");
}
poster.set_overflow(gtk::Overflow::Hidden);
poster.set_size_request(150, 225);
poster.set_halign(gtk::Align::Center);
+2
View File
@@ -803,6 +803,7 @@ fn spawn_fetch(
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
@@ -843,6 +844,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
+1 -1
View File
@@ -560,7 +560,7 @@ fn wake_and_connect(
None => {}
}
ticks += 1;
if ticks % 5 == 0 {
if ticks.is_multiple_of(5) {
rescan.request();
}
std::thread::sleep(Duration::from_secs(1));
+85 -28
View File
@@ -39,6 +39,10 @@ pub(crate) struct Game {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) store: String,
/// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title —
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
/// isn't `launcher` is a game" is decided in one place for every client.
pub(crate) launcher: bool,
}
#[derive(Clone, PartialEq, Default)]
@@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
@@ -215,6 +220,17 @@ fn initials(title: &str) -> String {
.collect()
}
/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows
/// both groups — a single unlabelled grid is what every launcher-less library looked like before.
fn group_heading(text: &str) -> Element {
text_block(text)
.font_size(12.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.margin(edges(2.0, 8.0, 2.0, 2.0))
.into()
}
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
fn poster_tile(
@@ -228,13 +244,20 @@ fn poster_tile(
.stretch(Stretch::UniformToFill)
.height(poster_h)
.into(),
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
// reads as "a game whose cover failed to load". So it names its launcher instead of
// showing a title monogram, and the frame below picks up the accent stroke.
None => border(
text_block(initials(&game.title))
.font_size(28.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
text_block(if game.launcher {
store_label(&game.store).to_string()
} else {
initials(&game.title)
})
.font_size(if game.launcher { 18.0 } else { 28.0 })
.semibold()
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
)
.background(ThemeRef::SubtleFill)
.height(poster_h)
@@ -242,14 +265,27 @@ fn poster_tile(
};
let framed = border(grid(vec![
poster,
pill(store_label(&game.store), Pill::Neutral)
.horizontal_alignment(HorizontalAlignment::Left)
.vertical_alignment(VerticalAlignment::Top)
.margin(uniform(6.0))
.into(),
// `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that
// white-on-bright is unreadable here.
pill(
store_label(&game.store),
if game.launcher {
Pill::Info
} else {
Pill::Neutral
},
)
.horizontal_alignment(HorizontalAlignment::Left)
.vertical_alignment(VerticalAlignment::Top)
.margin(uniform(6.0))
.into(),
]))
.corner_radius(8.0)
.border_brush(ThemeRef::CardStroke)
.border_brush(if game.launcher {
ThemeRef::Accent
} else {
ThemeRef::CardStroke
})
.border_thickness(uniform(1.0));
border(
@@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
.into(),
),
LibraryPhase::Ready(games) => {
let tiles: Vec<Element> = games
.iter()
.map(|g| {
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
let (target, id) = (target.clone(), g.id.clone());
poster_tile(
g,
props.state.art.get(&g.id).map(String::as_str),
poster_h,
Box::new(move || {
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
}),
)
})
.collect();
body.push(tile_grid(tiles, cols, POSTER_GAP));
let tile = |g: &Game| -> Element {
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
let (target, id) = (target.clone(), g.id.clone());
poster_tile(
g,
props.state.art.get(&g.id).map(String::as_str),
poster_h,
Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)),
)
};
// Design D4: launcher entries get their own shelf above the titles, never
// interleaved. `partition` is stable, so the host's title order survives in each
// group. Headings appear only when both groups exist, so a library without launcher
// entries renders exactly as it did before.
let (launchers, titles): (Vec<&Game>, Vec<&Game>) =
games.iter().partition(|g| g.launcher);
let both = !launchers.is_empty() && !titles.is_empty();
if !launchers.is_empty() {
if both {
body.push(group_heading("Launchers"));
}
body.push(tile_grid(
launchers.iter().map(|g| tile(g)).collect(),
cols,
POSTER_GAP,
));
}
if !titles.is_empty() {
if both {
body.push(group_heading("Games"));
}
body.push(tile_grid(
titles.iter().map(|g| tile(g)).collect(),
cols,
POSTER_GAP,
));
}
}
}
+16 -16
View File
@@ -5,9 +5,9 @@
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
//! drivable either way.
use crate::theme::{white, Fonts, W};
use crate::theme::{fg, Fonts, W};
use punktfunk_core::config::GamepadPref;
use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect};
use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GlyphStyle {
@@ -94,13 +94,13 @@ pub(crate) fn hint_bar(
let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None),
&Paint::new(crate::theme::shade(0.30), None),
);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
&Paint::new(white(0.06), None),
&Paint::new(fg(0.06), None),
);
let mut sp = Paint::new(white(0.12), None);
let mut sp = Paint::new(fg(0.12), None);
sp.set_style(skia_safe::PaintStyle::Stroke);
sp.set_stroke_width(1.0);
sp.set_anti_alias(true);
@@ -122,7 +122,7 @@ pub(crate) fn hint_bar(
cy + LABEL_SIZE * k * 0.36,
W::SemiBold,
LABEL_SIZE * k,
white(0.85),
fg(0.85),
);
pen += lw + gap_hint;
}
@@ -199,8 +199,8 @@ fn draw_glyph(
Resolved::Badge(face) => {
let r = BADGE_D * k / 2.0;
let center = Point::new((x + r) as f32, cy as f32);
canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None));
let mut ring = Paint::new(white(0.32), None);
canvas.draw_circle(center, r as f32, &Paint::new(fg(0.10), None));
let mut ring = Paint::new(fg(0.32), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width((1.2 * k) as f32);
ring.set_anti_alias(true);
@@ -223,7 +223,7 @@ fn draw_glyph(
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
fg(0.92),
);
}
}
@@ -235,7 +235,7 @@ fn draw_glyph(
let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32),
&Paint::new(white(0.10), None),
&Paint::new(fg(0.10), None),
);
let size = 10.0 * k;
let tw = fonts.measure(label, W::SemiBold, size) as f64;
@@ -246,7 +246,7 @@ fn draw_glyph(
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
fg(0.92),
);
pen += w + 3.0 * k;
}
@@ -257,7 +257,7 @@ fn draw_glyph(
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32);
let gap = (2.6 * k) as f32;
let paint = Paint::new(white(0.85), None);
let paint = Paint::new(fg(0.85), None);
let mut left = Path::new();
left.move_to((cx - gap, cyf - th));
left.line_to((cx - gap - tw, cyf));
@@ -277,9 +277,9 @@ fn draw_glyph(
let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32);
canvas.draw_rrect(
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
&Paint::new(white(0.10), None),
&Paint::new(fg(0.10), None),
);
let mut ring = Paint::new(white(0.28), None);
let mut ring = Paint::new(fg(0.28), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
@@ -296,7 +296,7 @@ fn draw_glyph(
cy + size * 0.36,
W::SemiBold,
size,
white(0.92),
fg(0.92),
);
}
}
@@ -305,7 +305,7 @@ fn draw_glyph(
/// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position
/// =□, Y-position=△ (the DualSense's physical layout).
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) {
let mut p = Paint::new(white(0.92), None);
let mut p = Paint::new(fg(0.92), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width(stroke);
p.set_stroke_cap(skia_safe::PaintCap::Round);
+359 -92
View File
@@ -205,62 +205,158 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [
// --- Background palettes -------------------------------------------------------------------
/// One background colour family for the console's living backdrop. A palette is NOT a second
/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to
/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright
/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look —
/// `violet` is the identity transform. The Apple and Android clients carry the same table and
/// the same [`tint`] math, so a palette reads as the same colour family on every client.
/// One background colour family for the gamepad UI's living backdrop.
///
/// A palette is a short ordered ramp of [`Palette::stops`] — several DISTINCT hues, not one hue
/// at several brightnesses. The 4×4 mesh samples that ramp diagonally with a per-cell offset
/// ([`CELL_RAMP`]), so neighbouring cells land on different parts of it and the colours pool and
/// swirl the way a real gradient poster does; the interior points' existing domain warp then
/// drifts those pools around. An earlier version rotated ONE field's hue per palette, which is
/// why every non-default palette read as flat and monotone.
///
/// A palette also owns the UI it sits under: [`Palette::accent`] is the focus wash / selected
/// pill / switch colour, and [`Palette::light`] flips the ink (see [`crate::theme::Ink`]) so a
/// pale field gets dark text instead of white. The Apple and Android clients carry the same
/// table under the same ids, so one `ui_palette` value is one look everywhere.
pub struct Palette {
/// The stored `ui_palette` value (see `trust::Settings::ui_palette`).
pub id: &'static str,
/// What the settings row shows.
pub name: &'static str,
/// Hue rotation about the grey axis, degrees — positive runs red → green → blue.
pub hue_deg: f64,
/// Saturation scale about luminance; `1.0` keeps the source saturation.
pub sat: f64,
/// The colour ramp, dark end first. `None` = use [`MESH_COLORS`] verbatim (the brand
/// default, kept bit-identical to what every install already sees).
pub stops: Option<&'static [(f64, f64, f64)]>,
/// The field's ground — what the corners settle onto and what the calm mix lifts toward.
pub ground: (f64, f64, f64),
/// The UI accent: focus wash, selected tab pill, switch track, caret.
pub accent: (f64, f64, f64),
/// A pale field: the UI flips to dark ink and the legibility scrims go white.
pub light: bool,
}
/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm,
/// then the neutral). Adding one here adds it to every console settings screen; the Apple
/// and Android tables must gain the same entry to keep the `ui_palette` key portable.
pub const PALETTES: [Palette; 6] = [
/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal
/// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one, like both
/// reference gradients — and the per-cell nudges break the banding that a pure diagonal would
/// give, so hues pool instead of striping.
#[rustfmt::skip]
const CELL_RAMP: [f64; 16] = [
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
];
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
/// gain the same entry to keep the `ui_palette` key portable.
#[rustfmt::skip]
pub const PALETTES: [Palette; 12] = [
// --- dark fields (white ink) ---
Palette {
id: "violet",
name: "Violet",
hue_deg: 0.0,
sat: 1.0,
id: "violet", name: "Violet", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
id: "tide",
name: "Tide",
hue_deg: -70.0,
sat: 1.0,
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
stops: Some(&[
(0.07, 0.05, 0.20), (0.26, 0.14, 0.54), (0.52, 0.20, 0.72),
(0.82, 0.26, 0.62), (0.98, 0.46, 0.68),
]),
ground: (0.055, 0.040, 0.135), accent: (0.95, 0.42, 0.72), light: false,
},
Palette {
id: "forest",
name: "Forest",
hue_deg: -130.0,
sat: 0.9,
// Ink-blue water: teal → cerulean → a violet undertow.
id: "abyss", name: "Abyss",
stops: Some(&[
(0.02, 0.10, 0.17), (0.04, 0.28, 0.42), (0.07, 0.46, 0.63),
(0.16, 0.38, 0.78), (0.26, 0.22, 0.58),
]),
ground: (0.018, 0.070, 0.130), accent: (0.26, 0.76, 0.92), light: false,
},
Palette {
id: "ember",
name: "Ember",
hue_deg: 105.0,
sat: 1.0,
// Banked coals: plum embers → crimson → burnt orange → gold.
id: "ember", name: "Ember",
stops: Some(&[
(0.16, 0.03, 0.10), (0.45, 0.06, 0.12), (0.72, 0.18, 0.06),
(0.90, 0.42, 0.08), (0.95, 0.68, 0.18),
]),
ground: (0.090, 0.035, 0.040), accent: (0.98, 0.62, 0.26), light: false,
},
Palette {
id: "rose",
name: "Rose",
hue_deg: 60.0,
sat: 0.95,
// Forest floor into moss and a lime break.
id: "moss", name: "Moss",
stops: Some(&[
(0.03, 0.11, 0.09), (0.06, 0.27, 0.20), (0.09, 0.45, 0.31),
(0.28, 0.61, 0.28), (0.58, 0.77, 0.31),
]),
ground: (0.025, 0.085, 0.070), accent: (0.48, 0.86, 0.46), light: false,
},
Palette {
id: "graphite",
name: "Graphite",
hue_deg: 0.0,
sat: 0.12,
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone, so even the restrained option has somewhere to go.
id: "graphite", name: "Graphite",
stops: Some(&[
(0.06, 0.07, 0.11), (0.15, 0.18, 0.25), (0.30, 0.31, 0.35),
(0.45, 0.42, 0.38), (0.60, 0.56, 0.49),
]),
ground: (0.055, 0.055, 0.070), accent: (0.78, 0.80, 0.86), light: false,
},
// --- pale fields (dark ink) ---
Palette {
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
id: "holo", name: "Holo",
stops: Some(&[
(0.99, 0.72, 0.90), (0.80, 0.60, 0.98), (0.58, 0.62, 0.99),
(0.55, 0.86, 0.98), (0.94, 0.98, 1.00),
]),
ground: (0.96, 0.92, 0.99), accent: (0.42, 0.28, 0.86), light: true,
},
Palette {
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
id: "sunset", name: "Sunset",
stops: Some(&[
(0.55, 0.45, 0.92), (0.86, 0.31, 0.66), (0.97, 0.26, 0.34),
(0.99, 0.51, 0.18), (1.00, 0.80, 0.22),
]),
ground: (0.98, 0.74, 0.34), accent: (0.64, 0.13, 0.44), light: true,
},
Palette {
// Peach into blush and lilac — the softest of the set.
id: "bloom", name: "Bloom",
stops: Some(&[
(1.00, 0.86, 0.72), (0.99, 0.73, 0.79), (0.95, 0.65, 0.89),
(0.82, 0.68, 0.96), (0.73, 0.79, 0.99),
]),
ground: (0.99, 0.90, 0.89), accent: (0.72, 0.24, 0.55), light: true,
},
Palette {
// First light: pale gold → coral → lilac.
id: "dawn", name: "Dawn",
stops: Some(&[
(1.00, 0.92, 0.70), (1.00, 0.80, 0.62), (0.99, 0.66, 0.62),
(0.90, 0.62, 0.78), (0.77, 0.69, 0.95),
]),
ground: (1.00, 0.93, 0.82), accent: (0.82, 0.33, 0.28), light: true,
},
Palette {
// Sea glass: mint → aqua → a pale sky.
id: "mint", name: "Mint",
stops: Some(&[
(0.82, 0.98, 0.90), (0.62, 0.94, 0.88), (0.55, 0.88, 0.95),
(0.63, 0.82, 0.99), (0.82, 0.87, 1.00),
]),
ground: (0.90, 0.98, 0.96), accent: (0.04, 0.42, 0.40), light: true,
},
Palette {
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
id: "opal", name: "Opal",
stops: Some(&[
(0.98, 0.92, 0.96), (0.87, 0.93, 0.99), (0.91, 0.99, 0.95),
(0.99, 0.96, 0.88), (0.94, 0.90, 0.99),
]),
ground: (0.97, 0.96, 0.99), accent: (0.36, 0.32, 0.44), light: true,
},
];
@@ -270,35 +366,58 @@ pub fn palette(id: &str) -> &'static Palette {
PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0])
}
/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader
/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped,
/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and
/// Kotlin: keep the three copies in step or the palettes drift apart between clients.
pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) {
let (r, g, b) = c;
let a = deg.to_radians();
let (sn, cs) = a.sin_cos();
let inv_sqrt3 = 1.0 / 3.0f64.sqrt();
let grey = (r + g + b) / 3.0 * (1.0 - cs);
// The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's
// own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer.
let rot = (
r * cs + (b - g) * inv_sqrt3 * sn + grey,
g * cs + (r - b) * inv_sqrt3 * sn + grey,
b * cs + (g - r) * inv_sqrt3 * sn + grey,
);
let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2;
let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0);
(mix(rot.0), mix(rot.1), mix(rot.2))
/// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). Ported
/// verbatim to Swift and Kotlin — keep the three copies in step or a palette drifts between
/// clients.
pub fn ramp(stops: &[(f64, f64, f64)], t: f64) -> (f64, f64, f64) {
match stops.len() {
0 => (0.0, 0.0, 0.0),
1 => stops[0],
n => {
let x = t.clamp(0.0, 1.0) * (n - 1) as f64;
let i = (x.floor() as usize).min(n - 2);
let f = x - i as f64;
let (a, b) = (stops[i], stops[i + 1]);
(
a.0 + (b.0 - a.0) * f,
a.1 + (b.1 - a.1) * f,
a.2 + (b.2 - a.2) * f,
)
}
}
}
impl Palette {
/// [`MESH_COLORS`] under this palette's transform.
/// The 16 mesh colours for this palette: the ramp sampled per cell (see [`CELL_RAMP`]), or
/// [`MESH_COLORS`] verbatim for the brand default.
pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] {
core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat))
let Some(stops) = self.stops else {
return MESH_COLORS;
};
core::array::from_fn(|i| {
let (x, y) = ((i % 4) as f64 / 3.0, (i / 4) as f64 / 3.0);
ramp(stops, 0.5 * (x + y) + CELL_RAMP[i])
})
}
/// Four drifting blob colours, for the clients that approximate the mesh with a blob field
/// (Android). Spread across the ramp so the field still shows several hues at once.
pub fn blob_colors(&self) -> [(f64, f64, f64); 4] {
let stops = self.stops.unwrap_or(&VIOLET_BLOBS);
core::array::from_fn(|i| ramp(stops, 0.15 + 0.25 * i as f64))
}
}
/// The brand default's blob ramp — the four colours the pre-palette Android/legacy-Apple field
/// used, kept so `violet` is unchanged there too.
const VIOLET_BLOBS: [(f64, f64, f64); 5] = [
(0.53, 0.47, 0.96),
(0.24, 0.20, 0.72),
(0.62, 0.30, 0.80),
(0.22, 0.38, 0.86),
(0.53, 0.47, 0.96),
];
/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and
/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable
/// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of
@@ -337,6 +456,11 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String {
// rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\
// so the uniform block stays 16-byte aligned under any packing rule).\n\
uniform float4 u_lift;\n\
// rgb = what the vignette and scrims tend toward (black under a dark palette, white\n\
// under a pale one — darkening a pastel field would strand the dark text on it), and\n\
// a = how hard. A pale field needs far less: mixing toward white at the dark field's\n\
// strength bleaches the chroma straight out of the gradient.\n\
uniform float4 u_scrim;\n\
\n\
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\
float bz(float t, float a, float b, float c, float d) {{\n\
@@ -380,15 +504,16 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String {
\x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\
\x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\
\x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm);\n\
\x20 col *= 1.0 - vig;\n\
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0)\n\
\x20 * mix(0.42, 0.21, calm) * u_scrim.a;\n\
\x20 col = mix(col, u_scrim.rgb, vig);\n\
\n\
\x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\
\x20 float v = xy.y / u_res.y;\n\
\x20 float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)\n\
\x20 : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)\n\
\x20 : mix(0.08, 0.40, (v - 0.68) / 0.32);\n\
\x20 col *= 1.0 - s;\n\
\x20 col = mix(col, u_scrim.rgb, s * u_scrim.a);\n\
\n\
\x20 return half4(half3(col), 1.0);\n\
}}\n",
@@ -419,6 +544,11 @@ pub struct LibraryGame {
pub id: String,
pub title: String,
pub store: String,
/// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a
/// title — design D4. The host's `role` field, already reduced to a boolean by
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
/// `launcher` is a game" rule lives in exactly one place.
pub launcher: bool,
}
struct Shared {
@@ -454,7 +584,15 @@ impl LibraryShared {
}
/// Loaded games → the carousel (empty = the empty scene).
///
/// **Launcher entries are moved to the front, keeping the host's title order within each
/// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic,
/// the art pump and every future consumer of this model all inherit the invariant for free —
/// a launcher tile is never buried in the middle of a 400-title shelf.
pub fn set_games(&self, games: Vec<LibraryGame>) {
let mut games = games;
// `sort_by_key` is stable, so this is a partition that preserves the incoming order.
games.sort_by_key(|g| !g.launcher);
let mut s = self.0.lock().unwrap();
s.phase = if games.is_empty() {
LibraryPhase::Empty
@@ -521,6 +659,52 @@ mod tests {
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
}
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
/// each group. The renderer's `launcher_count()` reads the launcher group as the prefix
/// `0..n`, so an interleaved list would silently mislabel the group heading.
#[test]
fn set_games_groups_launchers_first_and_keeps_title_order() {
let g = |title: &str, launcher: bool| LibraryGame {
id: format!("steam:{title}"),
title: title.to_string(),
store: "steam".into(),
launcher,
};
let shared = LibraryShared::default();
shared.set_games(vec![
g("Celeste", false),
g("Big Picture", true),
g("Portal 2", false),
g("Heroic", true),
]);
let (phase, games, _) = shared.snapshot();
assert!(matches!(phase, LibraryPhase::Ready));
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]);
assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2);
}
/// A library with no launcher entries is untouched — the whole point of the grouping being
/// invisible until a plugin actually publishes a launcher tile.
#[test]
fn set_games_leaves_a_launcher_less_library_alone() {
let shared = LibraryShared::default();
shared.set_games(
["Celeste", "Portal 2", "Tunic"]
.iter()
.map(|t| LibraryGame {
id: format!("steam:{t}"),
title: (*t).to_string(),
store: "steam".into(),
launcher: false,
})
.collect(),
);
let (_, games, _) = shared.snapshot();
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]);
}
#[test]
fn jump_clamps_onto_the_ends() {
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
@@ -602,49 +786,132 @@ mod tests {
assert_eq!(src.matches('{').count(), src.matches('}').count());
}
/// The brand default must be the IDENTITY transform — the shipped violet backdrop is
/// what every existing install already sees, and a palette table that quietly restyled
/// it would be a regression dressed as a feature.
/// The brand default must still be the SHIPPED field, colour for colour. Every install
/// already sees it, and a palette table that quietly restyled the default would be a
/// regression dressed as a feature.
#[test]
fn violet_is_the_untouched_shipped_field() {
assert_eq!(PALETTES[0].id, "violet");
for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) {
assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}");
assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}");
assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}");
}
assert!(
PALETTES[0].stops.is_none(),
"the default is the explicit grid"
);
assert_eq!(palette("violet").mesh_colors(), MESH_COLORS);
// An unknown name is a newer client's palette, not an error.
assert_eq!(palette("chartreuse").id, "violet");
assert_eq!(palette("").id, "violet");
}
/// The transform's two knobs do what they claim: a rotation moves the hue while holding
/// roughly the same luminance, and the saturation scale collapses toward grey. These are
/// the numbers the Swift and Kotlin ports have to reproduce.
/// Hue angle in degrees, or `None` for something too grey to have one.
fn hue(c: (f64, f64, f64)) -> Option<f64> {
let (r, g, b) = c;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
if d < 0.04 {
return None;
}
let h = if max == r {
60.0 * (((g - b) / d) % 6.0)
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
Some((h + 360.0) % 360.0)
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
/// exactly the complaint about the hue-rotation model this replaced. Measured as the
/// widest gap between any two of the 16 mesh colours' hue angles.
#[test]
fn tint_rotates_hue_and_scales_saturation() {
let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates
assert!(violet.2 > violet.0 && violet.2 > violet.1);
// +105° (Ember) turns the blue-dominant pool red-dominant.
let ember = tint(violet, 105.0, 1.0);
assert!(ember.0 > ember.2, "{ember:?} should be warm");
// 130° (Forest) turns it green-dominant.
let forest = tint(violet, -130.0, 1.0);
assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}");
// Graphite's saturation scale leaves the three channels nearly equal…
let grey = tint(violet, 0.0, 0.12);
let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2);
assert!(spread < 0.08, "{grey:?} spread {spread}");
// …at about the source's luminance (it desaturates, it doesn't dim).
let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2;
assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}");
// Every palette stays in gamut on every mesh colour.
fn every_palette_is_multi_tone() {
for p in &PALETTES {
for c in p.mesh_colors() {
let hues: Vec<f64> = p.mesh_colors().iter().filter_map(|c| hue(*c)).collect();
assert!(hues.len() >= 8, "{}: too few coloured cells", p.id);
let spread = hues
.iter()
.flat_map(|a| {
hues.iter().map(move |b| {
let d = (a - b).abs() % 360.0;
d.min(360.0 - d)
})
})
.fold(0.0f64, f64::max);
// Graphite and Opal are deliberately near-neutral; everything else must carry a
// real hue journey.
let floor = if matches!(p.id, "graphite" | "opal") {
20.0
} else {
45.0
};
assert!(spread >= floor, "{} spans only {spread:.0}° of hue", p.id);
}
}
/// Ids, order and the light/dark split are the cross-client contract — the Apple and
/// Android tables must match this exactly.
#[test]
fn table_matches_the_other_clients() {
let ids: Vec<&str> = PALETTES.iter().map(|p| p.id).collect();
assert_eq!(
ids,
[
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"bloom", "dawn", "mint", "opal",
]
);
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let first_light = PALETTES
.iter()
.position(|p| p.light)
.expect("some are light");
assert!(PALETTES[first_light..].iter().all(|p| p.light));
assert_eq!(first_light, 6);
}
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
/// its ink flips, so a mislabelled one would put dark text on a dark field.
#[test]
fn palettes_are_in_gamut_and_honest_about_lightness() {
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
for p in &PALETTES {
for c in p.mesh_colors().iter().chain(p.blob_colors().iter()) {
for v in [c.0, c.1, c.2] {
assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id);
}
}
let mean = p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
if p.light {
assert!(mean > 0.5, "{} is flagged light but means {mean:.2}", p.id);
assert!(luma(p.ground) > 0.6, "{}'s ground is dark", p.id);
} else {
assert!(mean < 0.45, "{} is flagged dark but means {mean:.2}", p.id);
assert!(luma(p.ground) < 0.2, "{}'s ground is light", p.id);
}
// The accent tints glass of the OPPOSITE polarity to the field, so it has to be
// legible there: dark accents on white frost, bright ones on dark glass.
let a = luma(p.accent);
if p.light {
assert!(a < 0.45, "{}'s accent is too pale for white glass", p.id);
} else {
assert!(a > 0.25, "{}'s accent is too dark for dark glass", p.id);
}
}
}
/// The ramp is the shared sampling rule the Swift and Kotlin ports reproduce.
#[test]
fn ramp_interpolates_between_stops() {
let stops = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 1.0)];
assert_eq!(ramp(&stops, 0.0), (0.0, 0.0, 0.0));
assert_eq!(ramp(&stops, 1.0), (1.0, 1.0, 1.0));
assert_eq!(ramp(&stops, 0.5), (1.0, 0.0, 0.0));
let q = ramp(&stops, 0.25);
assert!((q.0 - 0.5).abs() < 1e-9 && q.1 == 0.0);
// Out of range clamps rather than panicking.
assert_eq!(ramp(&stops, -3.0), (0.0, 0.0, 0.0));
assert_eq!(ramp(&stops, 9.0), (1.0, 1.0, 1.0));
assert_eq!(ramp(&[], 0.5), (0.0, 0.0, 0.0));
}
}
+2 -2
View File
@@ -7,7 +7,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -214,7 +214,7 @@ impl AddHostScreen {
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
DIM,
fg(0.55),
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
+22 -22
View File
@@ -10,7 +10,7 @@ use crate::glyphs::{Hint, HintKey};
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
use crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE};
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect};
@@ -251,7 +251,7 @@ impl HomeScreen {
"Hosts on this network appear automatically — add one by address for everything else.",
W::Regular,
13.0 * k,
DIM,
fg(0.55),
f64::from(rect.left) + w / 2.0,
cy + tile_h / 2.0 + 24.0 * k,
w * 0.7,
@@ -265,7 +265,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
canvas,
rect,
TILE_CORNER as f32,
h.saved.then(|| brand(0.20)),
h.saved.then(|| accent(0.20)),
if h.saved {
PanelStroke::Gradient
} else {
@@ -327,7 +327,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
sub_base,
W::Regular,
13.0 * k,
white(0.55),
fg(0.55),
max_w,
);
let x = l + addr_w + 8.0 * k;
@@ -352,7 +352,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
sub_base,
W::Regular,
13.0 * k,
white(0.55),
fg(0.55),
max_w,
);
}
@@ -364,22 +364,22 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
sub_base - 22.0 * k,
W::Bold,
23.0 * k,
WHITE,
fg(1.0),
max_w,
);
}
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
/// A profile's `#RRGGBB` accent as a color, defaulting to the PALETTE's accent. Parsed
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
let Some(hex) = accent
fn accent_color(hex: Option<&str>) -> skia_safe::Color4f {
let Some(hex) = hex
.and_then(|a| a.strip_prefix('#'))
.filter(|h| h.len() == 6)
else {
return BRAND;
return accent(1.0);
};
let Ok(v) = u32::from_str_radix(hex, 16) else {
return BRAND;
return accent(1.0);
};
skia_safe::Color4f::new(
((v >> 16) & 0xff) as f32 / 255.0,
@@ -404,9 +404,9 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32);
canvas.draw_rrect(
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
&Paint::new(brand(0.16), None),
&Paint::new(accent(0.16), None),
);
let mut ring = Paint::new(brand(0.5), None);
let mut ring = Paint::new(accent(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
@@ -415,7 +415,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
&ring,
);
let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k);
let mut p = Paint::new(BRAND, None);
let mut p = Paint::new(accent(1.0), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((3.0 * k) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
@@ -441,7 +441,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
sub_base,
W::Regular,
13.0 * k,
white(0.55),
fg(0.55),
max_w,
);
fonts.draw_clipped(
@@ -451,7 +451,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
sub_base - 22.0 * k,
W::Bold,
23.0 * k,
WHITE,
fg(1.0),
max_w,
);
}
@@ -467,8 +467,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
Point::new(badge.left, badge.bottom),
),
skia_safe::gradient_shader::GradientShaderColors::Colors(&[
BRAND.to_color(),
brand(0.68).to_color(),
accent(1.0).to_color(),
accent(0.68).to_color(),
]),
None,
skia_safe::TileMode::Clamp,
@@ -477,8 +477,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
));
canvas.draw_rrect(rr, &p);
} else {
canvas.draw_rrect(rr, &Paint::new(brand(0.16), None));
let mut ring = Paint::new(brand(0.5), None);
canvas.draw_rrect(rr, &Paint::new(accent(0.16), None));
let mut ring = Paint::new(accent(0.5), None);
ring.set_style(skia_safe::PaintStyle::Stroke);
ring.set_stroke_width(1.0);
ring.set_anti_alias(true);
@@ -499,13 +499,13 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6
y + 26.0 * k + size * 0.36,
W::Bold,
size,
if filled { WHITE } else { BRAND },
if filled { fg(1.0) } else { accent(1.0) },
);
}
/// A small padlock: filled body + stroked shackle (the paired-identity mark).
fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
let ink = white(0.5);
let ink = fg(0.5);
let body_w = 11.0 * k;
let body_h = 8.0 * k;
let body_top = y + 5.0 * k;
+88 -21
View File
@@ -12,7 +12,7 @@ use crate::library::{
};
use crate::model::{ConsoleCmd, HostRow};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{white, Fonts, DIM, W, WHITE};
use crate::theme::{accent, fg, Fonts, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
use std::collections::HashMap;
@@ -168,10 +168,31 @@ impl LibraryScreen {
}
}
/// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the
/// front, so the launcher group is always the prefix `0..launcher_count()`.
fn launcher_count(&self) -> usize {
self.games.iter().take_while(|g| g.launcher).count()
}
/// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a
/// game.)
fn focused_is_launcher(&self) -> bool {
self.games
.get(self.cursor as usize)
.is_some_and(|g| g.launcher)
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
match &self.phase {
LibraryPhase::Ready => vec![
Hint::new(HintKey::Confirm, "Play"),
Hint::new(
HintKey::Confirm,
if self.focused_is_launcher() {
"Open"
} else {
"Play"
},
),
Hint::new(HintKey::Shoulders, "Jump"),
Hint::new(HintKey::Back, "Back"),
],
@@ -216,7 +237,7 @@ impl LibraryScreen {
"Loading library…",
W::Regular,
14.0 * k,
DIM,
fg(0.55),
cx,
cy_all + 16.0 * k,
w * 0.8,
@@ -228,7 +249,7 @@ impl LibraryScreen {
"No games found",
W::Bold,
22.0 * k,
WHITE,
fg(1.0),
cx,
cy_all - 20.0 * k,
w * 0.8,
@@ -238,7 +259,7 @@ impl LibraryScreen {
"Install Steam titles or add custom entries in the host's web console.",
W::Regular,
14.0 * k,
DIM,
fg(0.55),
cx,
cy_all + 12.0 * k,
w * 0.8,
@@ -250,7 +271,7 @@ impl LibraryScreen {
&title,
W::Bold,
22.0 * k,
WHITE,
fg(1.0),
cx,
cy_all - 32.0 * k,
w * 0.8,
@@ -260,7 +281,7 @@ impl LibraryScreen {
&body,
W::Regular,
14.0 * k,
DIM,
fg(0.55),
cx,
cy_all + 4.0 * k,
(600.0 * k).min(w * 0.85),
@@ -277,6 +298,30 @@ impl LibraryScreen {
let pos = self.anim.pos;
let bump = self.bump.pos * k;
// Group heading. The model groups launcher entries at the front (design D4), and a
// coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav
// model, in three renderers, for two or three tiles) the heading names the group the
// cursor is in and changes as it crosses the boundary. Drawn only when the shelf
// actually has both groups, so a library without launchers looks exactly as before.
let launchers = self.launcher_count();
if launchers > 0 && launchers < self.games.len() {
let heading = if (self.cursor as usize) < launchers {
"LAUNCHERS"
} else {
"GAMES"
};
fonts.centered(
canvas,
heading,
W::SemiBold,
12.0 * k,
fg(0.5),
f64::from(rect.left) + w / 2.0,
cy - card_h / 2.0 - 22.0 * k,
w * 0.5,
);
}
// Paint order = draw order: farthest from the (integer) cursor first, so the
// dense side stacks overlap toward the focus.
let mut order: Vec<usize> = (0..self.games.len()).collect();
@@ -326,21 +371,31 @@ impl LibraryScreen {
}
None => {
// Solid face, not glass: the side cards OVERLAP.
canvas.draw_rect(
crect,
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
);
let mono = initials(&game.title);
let font = fonts.font(W::Bold, 38.0 * k);
let tw = font.measure_str(&mono, None).0;
//
// A launcher tile usually has no poster, and an art-less launcher drawn like
// an art-less game reads as "a game whose cover failed to load". So it gets
// the brand-tinted face and names its launcher, instead of a title monogram.
let face = if game.launcher {
Color4f::new(0.153, 0.137, 0.267, 1.0)
} else {
Color4f::new(0.118, 0.118, 0.145, 1.0)
};
canvas.draw_rect(crect, &Paint::new(face, None));
let (glyph, size, ink) = if game.launcher {
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
} else {
(initials(&game.title), 38.0 * k, fg(0.45))
};
let font = fonts.font(W::Bold, size);
let tw = font.measure_str(&glyph, None).0;
canvas.draw_str(
&mono,
&glyph,
Point::new(
(card_w as f32 - tw) / 2.0,
card_h as f32 / 2.0 + 13.0 * k as f32,
),
&font,
&Paint::new(white(0.45), None),
&Paint::new(ink, None),
);
}
}
@@ -351,13 +406,20 @@ impl LibraryScreen {
let tw = fonts.measure(label, W::SemiBold, size) as f64;
let (px, py) = (8.0 * k, 8.0 * k);
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
// Brand-filled for a launcher, smoked glass for a game — the one cue that
// survives being three cards deep in the recede.
let pill = if game.launcher {
accent(0.85)
} else {
crate::theme::shade(0.55)
};
canvas.draw_rrect(
RRect::new_rect_xy(
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
(bh / 2.0) as f32,
(bh / 2.0) as f32,
),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
&Paint::new(pill, None),
);
fonts.draw(
canvas,
@@ -366,7 +428,7 @@ impl LibraryScreen {
py + 14.0 * k,
W::SemiBold,
size,
WHITE,
fg(1.0),
);
}
// The brightness recede: an opaque-black veil, never whole-card alpha.
@@ -390,17 +452,22 @@ impl LibraryScreen {
&g.title,
W::Bold,
27.0 * k,
WHITE,
fg(1.0),
cx,
f64::from(rect.bottom) - 64.0 * k,
w * 0.8,
);
let sub = if g.launcher {
format!("{} · LAUNCHER", store_label(&g.store).to_uppercase())
} else {
store_label(&g.store).to_uppercase()
};
fonts.centered(
canvas,
&store_label(&g.store).to_uppercase(),
&sub,
W::Regular,
12.0 * k,
white(0.5),
fg(0.5),
cx,
f64::from(rect.bottom) - 30.0 * k,
w * 0.5,
+3 -3
View File
@@ -7,7 +7,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::model::{ConsoleCmd, HostRow, PairPhase};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{Fonts, DIM, ERROR, W};
use crate::theme::{fg, Fonts, ERROR, W};
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -297,7 +297,7 @@ impl PairScreen {
intro,
W::Regular,
13.0 * k,
DIM,
fg(0.55),
cx,
f64::from(rect.top) + 2.0 * k,
f64::from(rect.width()) * 0.72,
@@ -336,7 +336,7 @@ impl PairScreen {
"Pairing… confirm the PIN on the host",
W::Regular,
13.0 * k,
DIM,
fg(0.55),
cx + 10.0 * k,
status_y,
f64::from(rect.width()) * 0.6,
@@ -8,7 +8,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
@@ -115,7 +115,7 @@ impl PinHostsScreen {
"No saved hosts yet — pair with a host first, then pin this profile to it.",
W::Regular,
14.0 * k,
DIM,
fg(0.55),
cx,
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
f64::from(rect.width()) * 0.7,
@@ -157,7 +157,7 @@ impl PinHostsScreen {
"A pinned profile appears as its own card on the host — one press connects with it.",
W::Regular,
13.0 * k,
DIM,
fg(0.55),
cx,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
+2 -2
View File
@@ -11,7 +11,7 @@
use crate::glyphs::{Hint, HintKey};
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{Fonts, DIM, W};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
@@ -405,7 +405,7 @@ impl SettingsScreen {
detail,
W::Regular,
13.0 * k,
DIM,
fg(0.55),
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
+36 -19
View File
@@ -86,10 +86,16 @@ pub(crate) struct Shell {
/// re-colours under the cursor as the row is stepped, which is the whole point of putting
/// the picker on a screen the backdrop is behind.
mesh_palette: String,
/// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so
/// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down
/// The palette's ground × 0.4 — the calm lift, precomputed with `mesh`. Chosen so
/// `col*0.6 + lift` leaves the ground EXACTLY where it was and pulls the bright pools down
/// to it: the form screens lose the launcher's contrast, not its colour.
mesh_lift: [f32; 3],
/// The backdrop's scrim under this palette: rgb = what the vignette and scrims tend
/// toward (black on a dark field, white on a pale one), a = how hard. Kept with the ink.
mesh_scrim: [f32; 4],
/// The text/accent/glass the palette calls for, published to the whole crate once per
/// frame (see [`crate::theme::set_ink`]).
ink: crate::theme::Ink,
/// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into
/// (or out of) calm alongside the screen transition.
bg_mix: f64,
@@ -110,7 +116,7 @@ impl Shell {
) -> Result<Shell> {
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
let settings = trust::Settings::load();
let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?;
let (mesh, mesh_lift, mesh_scrim, ink) = build_mesh(&settings.ui_palette)?;
let bg_mix = match stack.last().expect("non-empty").background() {
Bg::Aurora => 0.0,
Bg::Form => 1.0,
@@ -135,6 +141,8 @@ impl Shell {
toast: None,
mesh,
mesh_lift,
mesh_scrim,
ink,
bg_mix,
glyphs: GlyphStyle::Keyboard,
chip: None,
@@ -206,9 +214,11 @@ impl Shell {
// because someone picked a colour.
if self.settings.ui_palette != self.mesh_palette {
match build_mesh(&self.settings.ui_palette) {
Ok((mesh, lift)) => {
Ok((mesh, lift, scrim, ink)) => {
self.mesh = mesh;
self.mesh_lift = lift;
self.mesh_scrim = scrim;
self.ink = ink;
self.mesh_palette = self.settings.ui_palette.clone();
}
Err(e) => {
@@ -468,8 +478,9 @@ impl Shell {
/// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the
/// former aurora-over-static-form crossfade is now a single uniform.
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) {
// Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4).
let uniforms: [f32; 8] = [
// Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4),
// u_scrim (float4).
let uniforms: [f32; 12] = [
w as f32,
h as f32,
t as f32,
@@ -478,11 +489,15 @@ impl Shell {
self.mesh_lift[1],
self.mesh_lift[2],
0.0,
self.mesh_scrim[0],
self.mesh_scrim[1],
self.mesh_scrim[2],
self.mesh_scrim[3],
];
// SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or
// invalid bit patterns, so reading it as bytes is sound; the slice is copied by
// SAFETY: `uniforms` is a local `[f32; 12]` — exactly 48 bytes — and `f32` has no padding
// or invalid bit patterns, so reading it as bytes is sound; the slice is copied by
// `Data::new_copy` before `uniforms` goes out of scope.
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 32) };
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 48) };
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
Some(shader) => {
let mut paint = Paint::default();
@@ -496,28 +511,30 @@ impl Shell {
}
}
/// Compile the mesh shader for a palette, returning it with its precomputed calm lift.
/// Compile the mesh shader for a palette and resolve everything else that palette decides:
/// the calm lift, the scrim direction, and the ink the whole UI draws with.
/// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`]
/// hands Skia is hand-packed, and a silent layout change would feed the field garbage
/// instead of failing.
fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> {
type MeshLook = (RuntimeEffect, [f32; 3], [f32; 4], crate::theme::Ink);
fn build_mesh(palette_id: &str) -> Result<MeshLook> {
let p = palette(palette_id);
let colors = p.mesh_colors();
let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None)
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
anyhow::ensure!(
effect.uniform_size() == 32,
"mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)",
effect.uniform_size() == 48,
"mesh uniform block is {} bytes, expected 48 (u_res, u_tc, u_lift, u_scrim)",
effect.uniform_size()
);
let corner = colors[0];
let ink = crate::theme::Ink::of(p);
let g = p.ground;
Ok((
effect,
[
(corner.0 * 0.4) as f32,
(corner.1 * 0.4) as f32,
(corner.2 * 0.4) as f32,
],
[(g.0 * 0.4) as f32, (g.1 * 0.4) as f32, (g.2 * 0.4) as f32],
[ink.scrim.r, ink.scrim.g, ink.scrim.b, ink.scrim.a],
ink,
))
}
+10 -9
View File
@@ -2,8 +2,8 @@
use crate::anim::{approach, ease_out_cubic};
use crate::glyphs::{hint_bar, Hint, HintKey};
use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE};
use skia_safe::{gradient_shader, Canvas, Color4f, Paint, Point, Rect, TileMode};
use crate::theme::{fg, Fonts, PanelStroke, W};
use skia_safe::{gradient_shader, Canvas, Paint, Point, Rect, TileMode};
use super::{Shell, BOTTOM_BAND};
@@ -118,7 +118,7 @@ impl Shell {
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
canvas.draw_rrect(
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None),
&Paint::new(crate::theme::shade(0.6), None),
);
crate::theme::panel(
canvas,
@@ -135,7 +135,7 @@ impl Shell {
by + bh / 2.0 + size * 0.36,
W::Medium,
size,
white(0.92),
fg(0.92),
);
canvas.restore();
}
@@ -167,14 +167,15 @@ impl Shell {
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
// console taking over rather than a card popping up.
self.draw_aurora(canvas, w, h, t, 0.0);
// A soft pool of shade under the centre seats the white text against a bright aurora.
// A soft pool of shade under the centre seats the text against a bright field —
// dark on a dark palette, light on a pale one, so it always separates.
let mut vignette = Paint::default();
vignette.set_shader(gradient_shader::radial(
Point::new(cx as f32, (h / 2.0) as f32),
(w.max(h) * 0.42) as f32,
gradient_shader::GradientShaderColors::Colors(&[
Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(),
Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(),
crate::theme::shade(0.5).to_color(),
crate::theme::shade(0.0).to_color(),
]),
None,
TileMode::Clamp,
@@ -193,7 +194,7 @@ impl Shell {
title,
W::SemiBold,
23.0 * k,
WHITE,
fg(1.0),
cx,
title_y,
w * 0.82,
@@ -204,7 +205,7 @@ impl Shell {
body,
W::Regular,
14.0 * k,
DIM,
fg(0.55),
cx,
title_y + 32.0 * k,
w * 0.66,
+7 -3
View File
@@ -5,7 +5,7 @@ use crate::glyphs::{hint_bar, GlyphStyle};
use crate::library::LibraryShared;
use crate::model::HostRow;
use crate::screens::{Bg, Ctx, Screen};
use crate::theme::{white, Fonts, PanelStroke, W, WHITE};
use crate::theme::{fg, Fonts, PanelStroke, W};
use pf_client_core::gamepad::PadInfo;
use pf_client_core::trust;
use skia_safe::{Canvas, Rect};
@@ -31,6 +31,10 @@ impl Shell {
.replace(now)
.map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05));
self.sync();
// Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
// the previous palette's text over the new palette's field.
crate::theme::set_ink(self.ink);
self.pads = pads.to_vec();
self.glyphs = GlyphStyle::from_pref(pad_pref);
self.chip = Some(pad.map_or_else(
@@ -168,7 +172,7 @@ impl Shell {
18.0 * k + 16.0 * k,
W::Medium,
size,
white(0.7),
fg(0.7),
);
}
@@ -224,7 +228,7 @@ impl LayerEnv<'_> {
&screen.title(&ctx),
W::Bold,
30.0 * self.k,
WHITE,
fg(1.0),
self.w / 2.0,
18.0 * self.k,
self.w * 0.7,
+22 -12
View File
@@ -249,25 +249,34 @@ fn dump_console_screens() {
dump(&mut s, 3, 25, "02-transition", true);
dump(&mut s, 40, 8, "03-settings", true);
// The Interface tab (5 shoulder presses along) leads with the Background row, so this frame
// shows both the strip mid-list and the palette picker
// The Interface tab (5 shoulder presses along) leads with the Background row, so these
// frames show the strip mid-list AND the palette picker. Palettes are set directly rather
// than by counting Confirm presses, so reordering the table can't silently shoot the wrong
// one. Each is a whole LOOK, not just a backdrop: accent, ink and scrim move together, so
// the pale ones must be eyeballed with dark text on them.
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
dump(&mut s, 40, 8, "03b-settings-interface", true);
// …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop
// behind these rows recolours live.
for _ in 0..3 {
s.handle_menu(MenuEvent::Confirm);
}
dump(&mut s, 40, 8, "03c-settings-ember", true);
// Back to the brand default and the first tab so the later scenes look like they always did.
for _ in 0..3 {
s.handle_menu(MenuEvent::Confirm);
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
s.settings.ui_palette = id.to_string();
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
}
// Back to the first tab so the later scenes look like they always did.
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpBack);
}
// …and the LAUNCHER at full contrast under a few of them — the backdrop's loudest form,
// and the one the palettes are really chosen by.
s.handle_menu(MenuEvent::Back);
dump(&mut s, 20, 8, "_settle", true);
for id in ["nebula", "sunset", "holo"] {
s.settings.ui_palette = id.to_string();
dump(&mut s, 40, 8, &format!("01-home-{id}"), true);
}
s.settings.ui_palette = "violet".to_string();
dump(&mut s, 20, 8, "_settle2", true);
s.handle_menu(MenuEvent::Tertiary); // back into Settings for the scenes below
dump(&mut s, 20, 8, "_settle3", true);
// Add Host with the keyboard tray up (keyboard glyph style: no pad).
s.handle_menu(MenuEvent::Back);
@@ -312,6 +321,7 @@ fn dump_console_screens() {
id: format!("steam:{i}"),
title: (*t).to_string(),
store: "steam".into(),
launcher: false,
})
.collect(),
);
+109 -20
View File
@@ -14,28 +14,117 @@ use skia_safe::{
Point, RRect, Rect, TileMode, Typeface,
};
// --- Palette -----------------------------------------------------------------------------
// --- Ink ----------------------------------------------------------------------------------
/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is
/// always dark. (Light surfaces use #6656F2; nothing here is light.)
pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0);
pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0);
pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55);
pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35);
/// The error/status red (the GTK client's #ff938a).
/// The error/status red (the GTK client's #ff938a). Fixed: a warning must not change meaning
/// with the wallpaper.
pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0);
pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0);
pub(crate) fn white(alpha: f32) -> Color4f {
Color4f::new(1.0, 1.0, 1.0, alpha)
/// Everything about the console's look that follows the chosen background palette: which way
/// the text runs, what the glass is made of, and the accent that marks focus.
///
/// The console UI was 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 the clash this exists to fix.
#[derive(Clone, Copy)]
pub(crate) struct Ink {
/// Primary text/glyph colour, opaque.
fg: Color4f,
/// Focus wash, selected pill, caret — the palette's own accent.
accent: Color4f,
/// The base fill every glass panel starts from.
glass: Color4f,
/// What the vignette and legibility scrims tend toward — black under a dark field, white
/// under a pale one (darkening a pastel field would strand the dark text on it) — with the
/// alpha carrying HOW HARD. A pale field needs far less: mixing toward white at the dark
/// field's strength bleaches the chroma straight out of the gradient.
pub(crate) scrim: Color4f,
}
pub(crate) fn brand(alpha: f32) -> Color4f {
Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha)
/// The shipped dark look — also what a test or a preview gets before any palette is applied.
const DARK_INK: Ink = Ink {
fg: Color4f::new(1.0, 1.0, 1.0, 1.0),
// The punktfunk brand violet, DARK-appearance value (#8678F5).
accent: Color4f::new(0.525, 0.471, 0.961, 1.0),
glass: Color4f::new(0.086, 0.086, 0.125, 0.62),
scrim: Color4f::new(0.0, 0.0, 0.0, 1.0),
};
impl Ink {
/// The ink a palette calls for. On a pale field the text goes near-black (tinted toward the
/// palette's own ground so it doesn't read as a foreign grey) and the glass turns to white
/// frost, which is what keeps a row legible over a bright gradient.
pub(crate) fn of(p: &crate::library::Palette) -> Ink {
let accent = Color4f::new(p.accent.0 as f32, p.accent.1 as f32, p.accent.2 as f32, 1.0);
if !p.light {
return Ink { accent, ..DARK_INK };
}
let g = p.ground;
Ink {
fg: Color4f::new(
(g.0 * 0.16) as f32,
(g.1 * 0.14) as f32,
(g.2 * 0.20) as f32,
1.0,
),
accent,
// More body than the dark glass carries: white frost over a bright gradient has
// far less to separate it from its backdrop than dark glass over a dark one.
glass: Color4f::new(1.0, 1.0, 1.0, 0.66),
scrim: Color4f::new(1.0, 1.0, 1.0, 0.45),
}
}
}
/// The dark-glass base fill every panel starts from.
const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62);
thread_local! {
/// The ink the CURRENT frame draws with. A thread-local rather than a parameter because
/// every widget, glyph and panel in the crate reads it and the console renders on exactly
/// one thread — threading an `Ink` through ~90 call sites would be all cost and no safety.
/// [`crate::shell::Shell::render`] sets it once per frame, before anything draws.
static INK: std::cell::Cell<Ink> = const { std::cell::Cell::new(DARK_INK) };
}
pub(crate) fn set_ink(ink: Ink) {
INK.with(|i| i.set(ink));
}
pub(crate) fn ink() -> Ink {
INK.with(std::cell::Cell::get)
}
/// The foreground at `alpha` — white on a dark palette, near-black on a pale one.
pub(crate) fn fg(alpha: f32) -> Color4f {
let c = ink().fg;
Color4f::new(c.r, c.g, c.b, alpha)
}
/// The palette's accent at `alpha`.
pub(crate) fn accent(alpha: f32) -> Color4f {
let c = ink().accent;
Color4f::new(c.r, c.g, c.b, alpha)
}
/// A wash laid UNDER text to seat it against the field — black on a dark palette, white on a
/// pale one. `alpha` is the dark-field strength; a pale field needs less (see [`Ink::scrim`]),
/// so it is scaled the same way the backdrop's own scrims are.
pub(crate) fn shade(alpha: f32) -> Color4f {
let s = ink().scrim;
Color4f::new(s.r, s.g, s.b, alpha * s.a)
}
/// Ink that reads ON the accent (a filled key, a selected pill): whichever of black or white
/// the accent has more room for. Chosen by luminance rather than by `light`, because an accent
/// is picked for contrast against the GLASS, not against the field.
pub(crate) fn on_accent() -> Color4f {
let a = ink().accent;
let luma = 0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b;
if luma > 0.55 {
Color4f::new(0.0, 0.0, 0.0, 1.0)
} else {
Color4f::new(1.0, 1.0, 1.0, 1.0)
}
}
// --- Panels (the Liquid Glass stand-in) --------------------------------------------------
@@ -61,7 +150,7 @@ pub(crate) fn panel(
k: f32,
) {
let rr = RRect::new_rect_xy(rect, corner * k, corner * k);
canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None));
canvas.draw_rrect(rr, &Paint::new(ink().glass, None));
if let Some(tint) = tint {
canvas.draw_rrect(rr, &Paint::new(tint, None));
}
@@ -71,10 +160,10 @@ pub(crate) fn panel(
sp.set_anti_alias(true);
match stroke {
PanelStroke::Plain(alpha) => {
sp.set_color4f(white(alpha), None);
sp.set_color4f(fg(alpha), None);
}
PanelStroke::Brand(alpha) => {
sp.set_color4f(brand(alpha), None);
sp.set_color4f(accent(alpha), None);
}
PanelStroke::Gradient | PanelStroke::GradientDashed => {
sp.set_shader(gradient_shader::linear(
@@ -83,8 +172,8 @@ pub(crate) fn panel(
Point::new(rect.left, rect.bottom),
),
gradient_shader::GradientShaderColors::Colors(&[
white(0.22).to_color(),
white(0.04).to_color(),
fg(0.22).to_color(),
fg(0.04).to_color(),
]),
None,
TileMode::Clamp,
@@ -122,7 +211,7 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph
/// The loading/connecting spinner: a rotating 270° arc driven by the shell clock.
pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
let start = (t * 300.0) % 360.0;
let mut paint = Paint::new(white(0.85), None);
let mut paint = Paint::new(fg(0.85), None);
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width((r / 5.0) as f32);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
+19 -17
View File
@@ -6,7 +6,7 @@
use crate::anim::{approach, Spring, TRAY_C, TRAY_K};
use crate::library::{BUMP_C, BUMP_K};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, FAINT, W, WHITE};
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
@@ -209,7 +209,7 @@ impl MenuList {
W::SemiBold,
12.0 * k,
1.4 * k,
white(0.45),
fg(0.45),
);
}
// Focus scale eases 0.98 → 1.0 about the row center.
@@ -226,9 +226,9 @@ impl MenuList {
PanelStroke::Plain(0.06 + 0.22 * f as f32)
};
let tint = if row.caret {
Some(brand(0.30))
Some(accent(0.30))
} else if f > 0.01 {
Some(brand(0.30 * f as f32))
Some(accent(0.30 * f as f32))
} else {
None
};
@@ -237,7 +237,7 @@ impl MenuList {
let baseline = cy + 16.0 * k * 0.36;
if row.value.is_none() {
// Action row: centered label, brand when actionable.
let color = if row.enabled { BRAND } else { FAINT };
let color = if row.enabled { accent(1.0) } else { fg(0.35) };
let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64;
fonts.draw(
canvas,
@@ -256,15 +256,15 @@ impl MenuList {
baseline,
W::SemiBold,
16.0 * k,
if row.enabled { WHITE } else { DIM },
if row.enabled { fg(1.0) } else { fg(0.55) },
);
let value = row.value.as_deref().unwrap_or_default();
let vcolor = if row.value_dim {
FAINT
fg(0.35)
} else if f > 0.5 {
WHITE
fg(1.0)
} else {
white(0.6 + 0.4 * f as f32)
fg(0.6 + 0.4 * f as f32)
};
let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 };
let caret_w = if row.caret { 8.0 * k } else { 0.0 };
@@ -282,7 +282,7 @@ impl MenuList {
(2.0 * k) as f32,
(18.0 * k) as f32,
),
&Paint::new(BRAND, None),
&Paint::new(accent(1.0), None),
);
}
if row.adjustable && f > 0.01 {
@@ -362,7 +362,7 @@ impl TabStrip {
canvas,
Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32),
(pill_h / 2.0 / k) as f32,
Some(brand(0.85)),
Some(accent(0.85)),
PanelStroke::Plain(0.22),
k as f32,
);
@@ -382,7 +382,7 @@ impl TabStrip {
baseline,
W::SemiBold,
size,
white(0.5 + 0.5 * covered),
fg(0.5 + 0.5 * covered),
);
x += widths[i] + gap;
}
@@ -407,7 +407,7 @@ fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> Stri
fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
let dir = if left { -1.0 } else { 1.0 };
let mut p = Paint::new(white(alpha), None);
let mut p = Paint::new(fg(alpha), None);
p.set_style(skia_safe::PaintStyle::Stroke);
p.set_stroke_width((1.8 * r / 4.0) as f32);
p.set_stroke_cap(skia_safe::PaintCap::Round);
@@ -594,7 +594,7 @@ impl Keyboard {
let focused = r == self.row && c == self.col;
let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32);
let fill = if focused {
let mut b = BRAND;
let mut b = accent(1.0);
if self.key_flash > 0.02 {
// A just-typed key flashes brighter, then eases back.
let f = self.key_flash as f32;
@@ -607,16 +607,18 @@ impl Keyboard {
}
b
} else {
white(0.08)
fg(0.08)
};
canvas.draw_rrect(
RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32),
&Paint::new(fill, None),
);
// The focused key is filled with the accent, so its letter needs ink that
// reads on THAT, not on the field.
let ink = if focused {
skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0)
crate::theme::on_accent()
} else {
WHITE
fg(1.0)
};
let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0);
match key {
+15 -61
View File
@@ -77,64 +77,21 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize {
((bitrate_bps / (8 * fps.max(1) as u64)) as usize).max(64 * 1024)
}
/// Raise this process's WDDM GPU scheduling priority so the wavelet encode isn't starved by a
/// GPU-bound game. PyroWave encodes on the GPU's compute/shader cores — the exact resource a game
/// saturates — so under load `pyrowave_encoder_encode_gpu_synchronous` spikes from ~2 ms to
/// 15-18 ms (measured, RTX 4090 at 95 % game load) and the stream fps collapses; NVENC is immune
/// because it runs on the separate encoder ASIC. HIGH sits above a game's NORMAL/ABOVE_NORMAL so
/// the WDDM scheduler services the encode's short compute bursts ahead of the game's rendering.
/// (REALTIME is deliberately avoided: it needs a privilege and would preempt the desktop
/// compositor too.) Best-effort + once-per-process: if the class isn't grantable we log and run at
/// normal priority — no session-fatal path.
fn raise_process_gpu_priority() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
use windows::Wdk::Graphics::Direct3D::{
D3DKMTSetProcessSchedulingPriorityClass, D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL,
D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME,
};
// `PUNKTFUNK_GPU_PRIORITY` = off | above-normal | high (default) | realtime. REALTIME can
// force finer WDDM preemption than HIGH but needs a privilege and preempts the compositor,
// so it stays opt-in; `off` skips the call entirely.
let (class, label) = match std::env::var("PUNKTFUNK_GPU_PRIORITY")
.ok()
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("off") => {
tracing::info!("PyroWave: PUNKTFUNK_GPU_PRIORITY=off — leaving GPU scheduling priority at default");
return;
}
Some("above-normal") | Some("above_normal") => {
(D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL, "ABOVE_NORMAL")
}
Some("realtime") => (D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME, "REALTIME"),
_ => (D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, "HIGH"),
};
// SAFETY: `GetCurrentProcess` returns the current-process pseudo-handle; the D3DKMT call
// only sets this process's GPU scheduling class — it creates/frees nothing.
let status = unsafe {
D3DKMTSetProcessSchedulingPriorityClass(GetCurrentProcess(), class)
};
if status.is_ok() {
tracing::info!(
priority = label,
"PyroWave: raised process GPU scheduling priority (WDDM) so the wavelet encode is \
serviced ahead of game rendering on the shared shader cores"
);
} else {
tracing::warn!(
?status,
priority = label,
"PyroWave: could not raise GPU scheduling priority (not grantable) — the encode \
may be starved under heavy game GPU load"
);
}
});
}
// GPU scheduling priority is deliberately NOT set here. `pf-frame`'s `dxgi::auto_priority_gate`
// owns that policy for the whole process and runs once from `create_device` — the call the Windows
// capture path always makes before any PyroWave texture exists.
//
// This module used to raise it itself, to HIGH, once per process. That was a SECOND owner of a
// process-wide setting and it raced the real one: pf-frame's default `auto` mode starts at HIGH and
// then UPGRADES to REALTIME once it has established that is safe (HAGS off, or HAGS on with VRAM
// headroom, with a monitor that drops back when VRAM tightens — REALTIME + NVIDIA + HAGS +
// near-full VRAM is a documented NVENC hang). Opening a PyroWave session after that upgrade stamped
// HIGH back over REALTIME and the monitor, silently losing the ceiling-raise on exactly the
// GPU-saturated workload PyroWave cares about most.
//
// The old `PUNKTFUNK_GPU_PRIORITY` knob went with it; `PUNKTFUNK_GPU_PRIORITY_CLASS`
// (`off|normal|high|realtime|auto`, default `auto`) is the one that survives and it is strictly
// more capable — the removed knob could not express the auto gate at all.
pub struct PyroWaveEncoder {
// pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side.
@@ -188,9 +145,6 @@ impl PyroWaveEncoder {
chroma: crate::ChromaFormat,
bit_depth: u8,
) -> Result<Self> {
// Prioritize the host's GPU work over a running game so the compute-shader encode gets
// scheduled promptly instead of queuing behind a full frame of the game's rendering.
raise_process_gpu_priority();
let chroma444 = chroma.is_444();
// A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style
// studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) —
+21 -5
View File
@@ -671,9 +671,10 @@ mod tests {
} else {
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
};
let url = file_url(std::path::Path::new(&path));
let mut art = Artwork {
portrait: Some(path.clone()),
hero: Some(format!("file://{path}")),
hero: Some(url),
logo: Some("https://cdn/l.png".into()),
header: None,
};
@@ -757,7 +758,7 @@ mod tests {
// half that matters for the extracted scanners: they emit `file://` values, so if the
// conversion happened after the confinement check the check would be inspecting a string
// that is not the path being read.
let as_url = format!("file://{}", cover.to_str().unwrap());
let as_url = file_url(&cover);
assert_eq!(
local_art_bytes(&as_url)
.expect("file:// reads the same cover")
@@ -766,15 +767,15 @@ mod tests {
);
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
assert!(
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
local_art_bytes(&file_url(&elsewhere)).is_none(),
"file:// must not escape the art roots"
);
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
// `..` check.
assert!(
local_art_bytes(&format!(
"file://{}/%2e%2e/{}/cover.png",
dir.to_str().unwrap(),
"{}/%2e%2e/{}/cover.png",
file_url(&dir),
outside.file_name().unwrap().to_str().unwrap()
))
.is_none(),
@@ -789,6 +790,21 @@ mod tests {
let _ = std::fs::remove_dir_all(&outside);
}
/// Build a `file://` value the way the kit's `fileUrl` does, so these tests exercise the real
/// plugin contract on both platforms. A POSIX path keeps the two-slash form
/// (`file:///home/u/c.png` — empty authority, then the leading `/`); a Windows path becomes
/// `file:///C:/covers/c.png`, i.e. three slashes and forward separators. Building it as
/// `format!("file://{path}")` on Windows yields `file://C:\covers\c.png`, whose authority is
/// `C:` — that is a UNC reference, not a local file, and the parser is right to refuse it.
fn file_url(p: &std::path::Path) -> String {
let posix = p.to_str().unwrap().replace('\\', "/");
if posix.starts_with('/') {
format!("file://{posix}")
} else {
format!("file:///{posix}")
}
}
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
/// reaches `library.json`. URLs are none of its business.
#[test]
+57 -1
View File
@@ -371,7 +371,7 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, `playnite`,
/// `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
@@ -461,6 +461,21 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
launch.value
));
}
// The value is interpolated into a `playnite://` URI, so it is charset-checked here as
// well as at launch time — same reasoning as the two kinds above.
if launch.kind == "playnite" && !valid_playnite_id(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID"
));
}
// `<Identity>!<AppId>`, both straight off `MicrosoftGame.config`. The host completes it
// into an AUMID at launch (it can read the publisher hash; the runner cannot), so the
// shape is checked here where the author can still act on the error.
if launch.kind == "xbox" && !valid_aumid(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `xbox` must be `<Identity>!<AppId>`"
));
}
}
if let Some(marker) = &e.detect.env_marker {
if !valid_env_key(&marker.key) {
@@ -743,6 +758,47 @@ mod tests {
assert!(library_id_for(&r3[0]).starts_with("custom:"));
}
/// A plugin's `launchers(cfg)` tile, end to end: `role: "launcher"` survives the reconcile onto
/// the stored entry AND onto the `GameEntry` a client renders, keeps the deterministic claimed
/// id, and stays out of the wire for ordinary games.
///
/// This is the path the lutris and heroic plugins publish through, and nothing exercised it
/// before — every earlier test reconciled `GameRole::Game`, which is the serde default, so the
/// field could have been dropped anywhere between the payload and the client without a failure.
#[test]
fn a_launcher_entry_survives_reconcile_onto_the_wire() {
let mut launcher = input("launcher", "Lutris");
launcher.role = GameRole::Launcher;
launcher.launch = Some(LaunchSpec {
kind: "launcher_ui".into(),
value: "lutris".into(),
});
let mut entries = Vec::new();
let out = reconcile_entries(
&mut entries,
"lutris",
Some("lutris"),
vec![launcher, input("42", "Some Game")],
);
assert_eq!(library_id_for(&out[0]), "lutris:launcher");
assert_eq!(out[0].role, GameRole::Launcher);
assert_eq!(out[1].role, GameRole::Game, "the game is untouched");
// Onto the wire: the client sees `role`, and `is_game` keeps it off ordinary entries.
let tile: GameEntry = out[0].clone().into();
assert_eq!(tile.role, GameRole::Launcher);
let v = serde_json::to_value(&tile).unwrap();
assert_eq!(v["role"], "launcher");
let game: GameEntry = out[1].clone().into();
let vg = serde_json::to_value(&game).unwrap();
assert!(
vg.get("role").is_none(),
"a game's role stays off the wire, so old clients are unaffected"
);
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
+206 -21
View File
@@ -184,22 +184,59 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
"aumid" => {
let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| {
let part = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
part(pfn) && part(app)
});
valid.then(|| {
(
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
None,
)
})
"aumid" => valid_aumid(&spec.value).then(|| {
(
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
None,
)
}),
// Xbox / Game Pass from a library PLUGIN: `<Identity>!<AppId>`, both read straight out of
// `MicrosoftGame.config`. The host completes it into the AUMID.
//
// This kind exists because of a measured privilege asymmetry (2026-08-06): resolving the
// PackageFamilyName means enumerating `%ProgramData%\…\AppRepository\Packages`, which is
// denied to `NT AUTHORITY\LocalService` — the principal the plugin runner runs as — and
// allowed to the host, which runs as LocalSystem. So the plugin sends what it can read and
// the host reads the authoritative publisher hash itself, at launch time.
//
// Resolving here rather than caching at install time also means a package update that
// changes the hash cannot leave a stale, unlaunchable tile behind.
"xbox" => {
let (identity, app_id) = spec.value.split_once('!')?;
if !aumid_part(identity) || !aumid_part(app_id) {
return None;
}
let pfn = xbox_pfn(identity)?;
Some((
format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""),
None,
))
}
// Playnite: open the game through Playnite's own URI handler, which is what actually knows
// how to start it (Playnite maps the id to whichever store owns the title). explorer.exe
// resolves the registered protocol as the user — the same pattern as the `epic` kind — and
// the id is GUID-validated, so the only variable part of the line is 36 hex-and-dash chars.
//
// This kind exists because the plugin used to publish `kind: "command"` (a `start ""` shell
// line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole
// reconcile — so without a typed kind the Playnite plugin cannot publish anything at all.
"playnite" => valid_playnite_id(&spec.value).then(|| {
(
format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value),
None,
)
}),
// A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned
// directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The
// value is the literal "playnite" — nothing from the entry reaches the command line — and
// the working directory is Playnite's own install dir, as a .NET app expects.
"launcher_ui" => match spec.value.as_str() {
"playnite" => playnite_fullscreen_exe().map(|exe| {
let dir = exe.parent().map(std::path::Path::to_path_buf);
(format!("\"{}\"", exe.display()), dir)
}),
_ => None,
},
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
// input — the same trust as the operator typing it — not a client-influenced string.
@@ -260,6 +297,38 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool {
matches!(value, "bigpicture" | "desktop")
}
/// One half of an AUMID (a package family name or an app id): non-empty, and no character that
/// could break out of the `shell:AppsFolder\…` argument. Both halves are host-derived, so this is
/// belt-and-braces — but the `xbox` kind now takes an Identity straight off a plugin's wire, which
/// makes it load-bearing rather than defensive.
pub(crate) fn aumid_part(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// A full `<PFN>!<AppId>` AUMID.
pub(crate) fn valid_aumid(value: &str) -> bool {
value
.split_once('!')
.is_some_and(|(pfn, app)| aumid_part(pfn) && aumid_part(app))
}
/// A Playnite game id: the GUID Playnite's own database uses, and the only client-influenced part
/// of a `playnite` launch. Interpolated into a URI handed to explorer.exe, so the charset is
/// validated first — 8-4-4-4-12 lowercase-or-uppercase hex with dashes, nothing else.
pub(crate) fn valid_playnite_id(value: &str) -> bool {
let groups = [8usize, 4, 4, 4, 12];
let mut parts = value.split('-');
for want in groups {
match parts.next() {
Some(p) if p.len() == want && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
_ => return false,
}
}
parts.next().is_none()
}
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
///
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
@@ -280,17 +349,71 @@ fn launcher_ui_stores() -> &'static [&'static str] {
{
&["heroic", "lutris"]
}
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
// verified activation, and an unverified guess would ship a tile that does nothing.
#[cfg(not(target_os = "linux"))]
// Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the
// Xbox app are still unwired — each needs its own verified activation, and an unverified guess
// would ship a tile that does nothing.
#[cfg(windows)]
{
&["playnite"]
}
#[cfg(not(any(target_os = "linux", windows)))]
{
&[]
}
}
/// Is this a `launcher_ui` value this host can resolve?
///
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
/// publishing a tile that does nothing when a user clicks it.
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
if !launcher_ui_stores().contains(&value) {
return false;
}
#[cfg(windows)]
if value == "playnite" {
return playnite_fullscreen_exe().is_some();
}
true
}
/// Windows: Playnite's **Fullscreen** app, if this host can find it.
///
/// Fullscreen rather than Desktop for two reasons: a launcher tile is opened from a couch over a
/// stream, and — verified on 2026-08-06 — the registered `playnite://` protocol handler points at
/// `Playnite.DesktopApp.exe`, so a URI cannot open fullscreen mode at all. The exe is launched
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
/// literal `"playnite"`.
///
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
#[cfg(windows)]
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
const EXE: &str = "Playnite.FullscreenApp.exe";
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
.into_iter()
.find_map(|root| {
RegKey::predef(root)
.open_subkey(KEY)
.ok()?
.get_value::<String, _>("InstallLocation")
.ok()
})
.map(std::path::PathBuf::from);
from_registry
.into_iter()
.chain(
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
)
.map(|dir| dir.join(EXE))
.find(|p| p.is_file())
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
@@ -566,9 +689,23 @@ mod tests {
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
}
#[cfg(not(target_os = "linux"))]
#[cfg(windows)]
{
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
// validation is resolution, so a box without Playnite refuses the entry rather than
// publishing a tile that does nothing when clicked.
assert_eq!(
valid_launcher_ui("playnite"),
playnite_fullscreen_exe().is_some()
);
// The Linux launchers, and the Windows ones whose activation is still unverified
// (Epic, GOG Galaxy, the Xbox app), stay refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
}
#[cfg(not(any(target_os = "linux", windows)))]
{
// No launcher UIs are wired on this OS, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
}
@@ -576,6 +713,54 @@ mod tests {
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
}
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
/// AppRepository (measured 2026-08-06), so it sends `<Identity>!<AppId>` and the host resolves
/// the publisher hash. The charset guard is load-bearing here — unlike `aumid`, this value
/// arrives over the wire.
#[test]
fn xbox_value_is_identity_bang_appid_and_charset_guarded() {
assert!(valid_aumid("Microsoft.Foo!Game"));
assert!(valid_aumid("A_b-c.d!App"));
// Both halves must be present and non-empty.
assert!(!valid_aumid("Microsoft.Foo"));
assert!(!valid_aumid("!Game"));
assert!(!valid_aumid("Microsoft.Foo!"));
assert!(!valid_aumid(""));
// Nothing that could break out of the `shell:AppsFolder\…` argument.
assert!(!valid_aumid("Foo\"!Game"));
assert!(!valid_aumid("Foo!Game\" & calc"));
assert!(!valid_aumid("Foo\\..\\Bar!Game"));
assert!(!valid_aumid("Foo Bar!Game"));
}
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
#[cfg(windows)]
#[test]
fn playnite_launcher_opens_the_fullscreen_app() {
let ui = |v: &str| {
windows_launch_for(&LaunchSpec {
kind: "launcher_ui".into(),
value: v.into(),
})
};
// A launcher this host cannot open is refused, whatever the OS.
assert!(ui("gog").is_none());
assert!(ui("heroic").is_none());
assert!(ui("").is_none());
// The rest only means anything on a box that actually has Playnite.
let Some(exe) = playnite_fullscreen_exe() else {
return;
};
let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found");
assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}");
assert!(!cmd.contains("DesktopApp"), "{cmd}");
assert!(!cmd.contains("playnite://"), "{cmd}");
assert_eq!(dir.as_deref(), exe.parent());
}
#[cfg(target_os = "linux")]
#[test]
fn launcher_ui_opens_the_launcher_itself() {
+7 -1
View File
@@ -134,8 +134,14 @@ fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String
/// Resolve a package's PackageFamilyName by finding its
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
///
/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is
/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the
/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the
/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of
/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`).
#[cfg(windows)]
fn xbox_pfn(identity: &str) -> Option<String> {
pub(crate) fn xbox_pfn(identity: &str) -> Option<String> {
let pkgs = PathBuf::from(std::env::var_os("ProgramData")?)
.join("Microsoft")
.join("Windows")
+2 -1
View File
@@ -31,7 +31,8 @@ fn check_entry_fields(
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
heroic, playnite) \
instead"
),
));
+23 -8
View File
@@ -234,18 +234,33 @@ The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen)
already in the lockfile, via a generated-and-committed `bun.nix` (`web/bun.nix`, `sdk/bun.nix`).
There is **no aggregate deps hash to bump** — the previous design put `bun install` in a
fixed-output derivation whose single `outputHash` silently went stale on every lockfile change and
broke the build. `bun.nix` regenerates itself: `bun2nix` is a devDependency of both packages and
runs on every `bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the
*published* `@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
Regenerate by hand with `cd web && bunx bun2nix -o bun.nix` if a lockfile is ever edited directly.
broke the build. `bun2nix` is a devDependency of both packages and regenerates `bun.nix` on every
`bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the *published*
`@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
The `@unom` scope needs no special handling: `web/bun.lock` records those tarballs' full
`https://git.unom.io/api/packages/unom/npm/…` URLs and the registry is read-public (the same
anonymous pull CI's rpm/deb builds do).
> ⚠ **`bun.nix` has no schema stability across bun2nix versions.** The flake input is pinned
> (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency is pinned to the *same*
> exact version in `web/package.json` + `sdk/package.json`. Move both together, then rerun
> `bun install` in `web/` and `sdk/` to regenerate.
> ⚠⚠ **That devDependency hook is a convenience, NOT the guarantee — `bun.nix` still drifts.**
> It fires only on a local `bun install` that runs lifecycle scripts. It does *not* fire under
> `bun install --ignore-scripts`, which is what every bun install in CI uses; and it cannot fire
> on a **merge or rebase**, where git carries someone else's `bun.lock` change past a `bun.nix`
> generated before it and reports no conflict. That is how `web/bun.nix` shipped on main holding
> `brace-expansion@5.0.7` while `web/bun.lock` said `5.0.8` — for **553 commits** (2026-07-27 →
> 2026-08-05), with `nix build .#punktfunk-web` broken the whole time, until an unrelated
> advisory bump happened to rerun a real `bun install` and closed it by accident.
>
> The enforcement point is **`scripts/ci/check-bun-nix.sh`** (the `bun-nix` job in `ci.yml`,
> unfiltered so it sees the innocuous-looking commits drift arrives through). It regenerates each
> `bun.nix` from its committed `bun.lock` and diffs. Fix any report with:
>
> scripts/ci/check-bun-nix.sh --fix
>
> Never regenerate with a bare `bunx bun2nix`: **`bun.nix` has no schema stability across bun2nix
> versions**, and an unpinned `bunx` uses whatever is newest. The flake input
> (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency in `web/package.json` +
> `sdk/package.json` must name the *same exact version* — the script checks that too, and always
> generates with the pinned one. Move all three together, then rerun it with `--fix`.
Everything past the deps fetch is offline (the console's codegen + vite build; the runner's
`bun build --target=bun` bundle). Both launchers exec `pkgs.bun` from the store — unlike the
+181
View File
@@ -0,0 +1,181 @@
#!/bin/sh
# Drift gate for the generated bun2nix lockfile expressions (web/bun.nix, sdk/bun.nix).
#
# `bun.nix` is a DERIVED file: bun2nix is a pure function of `bun.lock` (it reads the lockfile text
# and emits one `fetchurl` per package, keyed by the lockfile's own integrity hashes — see
# packaging/nix/README.md). Nothing but the lockfile goes in, so any disagreement between the two
# committed files is drift, and it is always mechanically fixable.
#
# Why this exists: moving the bun packages to bun2nix (1db8f763) removed the *aggregate deps hash*
# that used to go stale, but not the second, quieter way a derived file rots. `bun.nix` regenerates
# only from a local `bun install` that runs lifecycle scripts (web's `postinstall`, the SDK's
# `prepare`). It does NOT regenerate on:
#
# * `bun install --ignore-scripts` — which is what EVERY bun install in CI uses (ci.yml,
# web-screenshots.yml, windows-host.yml, sdk-publish.yml), because web's `postinstall` shells
# out to a `bun` on PATH that CI's portable bun isn't;
# * a merge or rebase — git merges `bun.lock` and `bun.nix` as two unrelated files, so a branch
# that generated `bun.nix` before picking up someone else's lockfile change silently commits
# the pair out of step;
# * a lockfile edited or re-resolved by hand.
#
# That second case is not hypothetical: it is how `web/bun.nix` shipped on main carrying
# brace-expansion@5.0.7 (plus two nested entries the override had already collapsed) while
# `web/bun.lock` said 5.0.8 — the `^5.0.8` override from ec9aa415 landed in the lockfile, the
# bun2nix branch had generated `bun.nix` off the pre-override lock, and the merge kept both. The
# Nix build fetches node_modules strictly from `bun.nix`, so the offline `bun install` inside the
# derivation is then asked for a tarball the store cache does not contain and `punktfunk-web` fails
# to build — with a "package not found" that names npm, not the lockfile that actually drifted.
#
# The gate also enforces the version pin the flake and README only *state*: `bun.nix` has no schema
# stability across bun2nix releases, so the flake input ref and BOTH npm devDependencies must name
# the same exact version. Nothing checked that before; a half-moved pin regenerates the file with a
# generator the flake does not use.
#
# The list of packages to check is read out of packaging/nix/packages.nix (its `bunNix = src + …`
# lines) rather than hardcoded here, so a third bun package is covered the day it is added — and an
# empty list is a hard error, because a gate that checks nothing passes exactly like a clean tree.
#
# Usage:
# scripts/ci/check-bun-nix.sh # verify; non-zero on drift (CI)
# scripts/ci/check-bun-nix.sh --fix # regenerate the committed files in place
set -eu
FIX=0
if [ $# -gt 0 ]; then
case "$1" in
--fix) FIX=1 ;;
*) echo "usage: $0 [--fix]" >&2; exit 2 ;;
esac
fi
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
PACKAGES_NIX="$ROOT/packaging/nix/packages.nix"
FLAKE="$ROOT/flake.nix"
command -v bun >/dev/null 2>&1 || {
echo "check-bun-nix: bun is not on PATH (needed to run bun2nix and to read package.json)" >&2
exit 1
}
[ -f "$PACKAGES_NIX" ] || { echo "check-bun-nix: no $PACKAGES_NIX" >&2; exit 1; }
[ -f "$FLAKE" ] || { echo "check-bun-nix: no $FLAKE" >&2; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# --- the pinned bun2nix version -------------------------------------------------------------------
# flake.nix: url = "github:nix-community/bun2nix?ref=2.1.2";
PINNED=$(sed -n 's/.*github:nix-community\/bun2nix?ref=\([^"]*\)".*/\1/p' "$FLAKE" | head -1)
[ -n "$PINNED" ] || {
echo "check-bun-nix: could not read the bun2nix input ref out of $FLAKE." >&2
echo "Expected a line like: url = \"github:nix-community/bun2nix?ref=<version>\";" >&2
exit 1
}
# --- which packages carry a generated bun.nix -----------------------------------------------------
# packages.nix: bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/web/bun.nix"; };
sed -n 's/.*bunNix *= *src *+ *"\/\(.*\)\/bun\.nix".*/\1/p' "$PACKAGES_NIX" | sort -u > "$TMP/roots"
if [ ! -s "$TMP/roots" ]; then
echo "check-bun-nix: found no \`bunNix = src + \"/<dir>/bun.nix\"\` in $PACKAGES_NIX." >&2
echo "Either the bun packages were removed (delete this gate) or the expression changed shape" >&2
echo "and the gate silently stopped checking anything. Not passing vacuously." >&2
exit 1
fi
fail=0
checked=0
# --- version pin agreement ------------------------------------------------------------------------
# `bun.nix` has no schema stability across bun2nix versions, so the generator the flake builds with
# and the generator `bun install` runs must be the SAME exact version (packaging/nix/README.md).
while read -r dir; do
pkgjson="$ROOT/$dir/package.json"
[ -f "$pkgjson" ] || { echo "check-bun-nix: no $pkgjson" >&2; fail=1; continue; }
dev=$(bun -e "const d=require(process.argv[1]).devDependencies||{};console.log(d.bun2nix??'')" \
"$pkgjson")
if [ "$dev" != "$PINNED" ]; then
echo "check-bun-nix: bun2nix version pin disagrees." >&2
echo " flake.nix input ref : $PINNED" >&2
echo " $dir/package.json devDependency : ${dev:-<absent>}" >&2
echo "These must be the same exact version — bun.nix has no schema stability across" >&2
echo "bun2nix releases. Move both together, then rerun this script with --fix." >&2
fail=1
fi
done < "$TMP/roots"
# --- the generator ---------------------------------------------------------------------------------
# Prefer an already-installed bun2nix at the pinned version (fast, offline — the dev case); otherwise
# fetch exactly the pinned one, once, into $TMP. Never a floating `bunx bun2nix`: that would generate
# with whatever is newest, and `bun.nix` has no schema stability across releases.
BUN2NIX=""
while read -r dir; do
cand="$ROOT/$dir/node_modules/bun2nix/index.ts"
[ -f "$cand" ] || continue
have=$(bun -e "console.log(require(process.argv[1]).version??'')" \
"$ROOT/$dir/node_modules/bun2nix/package.json" 2>/dev/null || echo '')
if [ "$have" = "$PINNED" ]; then BUN2NIX="$cand"; break; fi
done < "$TMP/roots"
if [ -z "$BUN2NIX" ]; then
# Installed in its own scratch dir, so this never touches the repo's lockfiles or .npmrc.
mkdir -p "$TMP/gen"
if ! ( cd "$TMP/gen" && bun add --exact "bun2nix@$PINNED" ) > "$TMP/geninstall.log" 2>&1; then
echo "check-bun-nix: could not install bun2nix@$PINNED" >&2
cat "$TMP/geninstall.log" >&2
exit 1
fi
BUN2NIX="$TMP/gen/node_modules/bun2nix/index.ts"
[ -f "$BUN2NIX" ] || { echo "check-bun-nix: bun2nix@$PINNED installed but $BUN2NIX is absent" >&2; exit 1; }
fi
run_bun2nix() { # <lockfile> <outfile>
bun "$BUN2NIX" --lock-file "$1" --output-file "$2"
}
# --- regenerate + compare ---------------------------------------------------------------------------
while read -r dir; do
lock="$ROOT/$dir/bun.lock"
nix="$ROOT/$dir/bun.nix"
[ -f "$lock" ] || { echo "check-bun-nix: no $lock (packages.nix expects $dir/bun.nix)" >&2; fail=1; continue; }
out="$TMP/$(echo "$dir" | tr '/' '_').bun.nix"
run_bun2nix "$lock" "$out" >/dev/null
if [ "$FIX" -eq 1 ]; then
if [ ! -f "$nix" ] || ! cmp -s "$nix" "$out"; then
cp "$out" "$nix"
echo "check-bun-nix: regenerated $dir/bun.nix from $dir/bun.lock"
else
echo "check-bun-nix: $dir/bun.nix already in sync"
fi
checked=$((checked + 1))
continue
fi
if [ ! -f "$nix" ]; then
echo "check-bun-nix: $dir/bun.nix is MISSING — packages.nix fetches node_modules from it." >&2
fail=1
continue
fi
# Plain files, not `diff <(…) <(…)`: Gitea's runner executes a step's `run:` under `sh`, and
# dash has no process substitution — it would reject the script at parse time and the gate
# would never compare anything (exactly how the shader SPIR-V gate in ci.yml was lost).
if cmp -s "$nix" "$out"; then
echo "check-bun-nix: $dir/bun.nix matches $dir/bun.lock"
else
echo "check-bun-nix: $dir/bun.nix is STALE — it does not match $dir/bun.lock." >&2
echo "The Nix build fetches node_modules only from bun.nix, so punktfunk's bun packages" >&2
echo "would build against the wrong dependency set (or fail to fetch it at all)." >&2
echo "Regenerate and commit it: scripts/ci/check-bun-nix.sh --fix" >&2
echo "--- diff (committed -> regenerated from bun.lock) ---" >&2
diff -u "$nix" "$out" >&2 || true
fail=1
fi
checked=$((checked + 1))
done < "$TMP/roots"
[ "$checked" -gt 0 ] || { echo "check-bun-nix: checked nothing — refusing to report success" >&2; exit 1; }
if [ "$fail" -eq 0 ] && [ "$FIX" -eq 0 ]; then
echo "check-bun-nix: $checked bun package(s) in sync, bun2nix pinned at $PINNED everywhere"
fi
exit "$fail"