Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96959c984f | ||
|
|
a4bf4c276e | ||
|
|
a308ca337f | ||
|
|
ca1f36ac62 | ||
|
|
8e82a175ee | ||
|
|
610f8b9cbe | ||
|
|
77834be4e4 | ||
|
|
d2b86b103f | ||
|
|
68486b6ac8 | ||
|
|
3bfed02e02 |
@@ -288,8 +288,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// landing first and its neighbours fanning outward to either side.
|
||||
private func entrance(_ idx: Int) -> CardEntrance {
|
||||
// Capped so a several-hundred-title library never queues a card behind a visibly long
|
||||
// wait — everything past the cap lands together, well off-screen anyway.
|
||||
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
|
||||
// wait — everything past the cap lands together, well off-screen anyway. The stagger and
|
||||
// the cap move as a PAIR: their ratio is how many steps actually fan, so a wider offset
|
||||
// under the same cap would land the outer half of the strip in one block.
|
||||
// Mirrors `entrances::CARDS` in the desktop console's anim.rs — nothing pins the two
|
||||
// together, so a change here is a change there.
|
||||
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.12)
|
||||
return CardEntrance(
|
||||
progress: entranceProgress,
|
||||
start: delay / CardEntrance.total,
|
||||
@@ -499,9 +503,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||
/// unstaggered cross-fade.
|
||||
struct CardEntrance: ViewModifier, Animatable {
|
||||
/// How long ONE card takes to travel, and the most any card waits before it starts.
|
||||
/// How long ONE card takes to travel, and the most any card waits before it starts. The
|
||||
/// pair matches `entrances::CARDS` in the desktop console's anim.rs; `maxDelay` divided by
|
||||
/// the per-step stagger in `entrance(_:)` is the number of cards that visibly fan, which is
|
||||
/// why it moves whenever that stagger does.
|
||||
static let perCard: Double = 0.6
|
||||
static let maxDelay: Double = 0.42
|
||||
static let maxDelay: Double = 0.6
|
||||
/// The master timeline the carousel animates 0 → 1.
|
||||
static var total: Double { perCard + maxDelay }
|
||||
|
||||
|
||||
@@ -27,10 +27,9 @@ Built in Rust end to end (no C ABI): the shell shares its plumbing with the sess
|
||||
First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on
|
||||
a pinned identity.
|
||||
- **Per-host speed test** to pick a bitrate, plus compositor and mode preferences in Settings.
|
||||
- **Game library browser** *(experimental, off by default)* — "Browse library…" on a saved host
|
||||
shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
|
||||
Fetched from the host's management API over mTLS — paired devices are authorized by their
|
||||
certificate, no extra host setup.
|
||||
- **Game library browser** — "Browse library…" on a paired host shows its games (Steam + custom)
|
||||
as a poster grid; click one to launch it in the session. Fetched from the host's management API
|
||||
over mTLS — paired devices are authorized by their certificate, no extra host setup.
|
||||
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven library view
|
||||
of a paired host's games, rendered by the session binary's Skia console UI: A plays the focused
|
||||
title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; session end
|
||||
|
||||
@@ -75,7 +75,6 @@ enum CardKind {
|
||||
host: KnownHost,
|
||||
online: bool,
|
||||
recent: bool,
|
||||
library_enabled: bool,
|
||||
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
||||
/// refresh rather than re-read per card.
|
||||
profiles: Rc<Vec<Profile>>,
|
||||
@@ -293,7 +292,6 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
host: k,
|
||||
online,
|
||||
recent,
|
||||
library_enabled,
|
||||
profiles,
|
||||
pinned,
|
||||
} => {
|
||||
@@ -470,7 +468,9 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
// START this card, not a property of the host, so it belongs to a shortcut
|
||||
// as much as Connect does — and the card's request carries its profile, so
|
||||
// what launches from that grid is this card's profile, not the binding.
|
||||
if *library_enabled {
|
||||
// Paired only: the fetch authenticates as this device, so on a merely
|
||||
// trusted host it can only come back refused.
|
||||
if k.paired {
|
||||
launch.append(Some("Browse library\u{2026}"), Some("card.library"));
|
||||
}
|
||||
menu.append_section(None, &launch);
|
||||
@@ -513,8 +513,10 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
}
|
||||
|
||||
let look = gio::Menu::new();
|
||||
// Experimental (Preferences gate): browse the host's game library.
|
||||
if *library_enabled {
|
||||
// Browse the host's game library — offered on any paired host, but only a
|
||||
// paired one: a saved card can be merely "Trusted" (the pill above), and
|
||||
// the fetch authenticates as this device, so there it would only be refused.
|
||||
if k.paired {
|
||||
look.append(Some("Browse library\u{2026}"), Some("card.library"));
|
||||
}
|
||||
look.append(Some("Test network speed\u{2026}"), Some("card.speed"));
|
||||
@@ -677,7 +679,6 @@ pub struct HostsPage {
|
||||
/// [`saved_key`]. OR'd with live-advert presence to drive the Online pip.
|
||||
probed: HashMap<String, bool>,
|
||||
connecting: Option<String>,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
saved: FactoryVecDeque<HostCard>,
|
||||
discovered: FactoryVecDeque<HostCard>,
|
||||
widgets: PageWidgets,
|
||||
@@ -701,7 +702,7 @@ pub enum HostsMsg {
|
||||
AdvertRemoved {
|
||||
fullname: String,
|
||||
},
|
||||
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
|
||||
/// Reload the disk store and re-render (fresh pairings, renames).
|
||||
Refresh,
|
||||
/// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`],
|
||||
/// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a
|
||||
@@ -745,7 +746,10 @@ impl SimpleComponent for HostsPage {
|
||||
}
|
||||
|
||||
fn init(
|
||||
settings: Self::Init,
|
||||
// The shared settings store, which this page no longer reads: its card menus follow
|
||||
// pairing alone now that the library is offered on every paired host. It stays in
|
||||
// `Init` because the shell hands the same store to every page it launches.
|
||||
_settings: Self::Init,
|
||||
page: Self::Root,
|
||||
sender: ComponentSender<Self>,
|
||||
) -> ComponentParts<Self> {
|
||||
@@ -941,7 +945,6 @@ impl SimpleComponent for HostsPage {
|
||||
adverts: HashMap::new(),
|
||||
probed: HashMap::new(),
|
||||
connecting: None,
|
||||
settings,
|
||||
saved,
|
||||
discovered,
|
||||
widgets: PageWidgets {
|
||||
@@ -1064,7 +1067,6 @@ impl HostsPage {
|
||||
.filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t)))
|
||||
.max_by_key(|&(_, t)| t)
|
||||
.map(|(fp, _)| fp);
|
||||
let library_enabled = self.settings.borrow().library_enabled;
|
||||
// One catalog read per refresh, shared by every card's menus and chip.
|
||||
let profiles: Rc<Vec<Profile>> = Rc::new(
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
@@ -1120,7 +1122,6 @@ impl HostsPage {
|
||||
online,
|
||||
profiles: profiles.clone(),
|
||||
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
|
||||
library_enabled,
|
||||
pinned: None,
|
||||
},
|
||||
});
|
||||
@@ -1141,7 +1142,6 @@ impl HostsPage {
|
||||
online,
|
||||
profiles: profiles.clone(),
|
||||
recent: false,
|
||||
library_enabled,
|
||||
pinned: Some((id, name)),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1394,13 +1394,6 @@ pub fn show_scoped(
|
||||
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||
&["Off", "Compact", "Normal", "Detailed"],
|
||||
);
|
||||
let library_row = adw::SwitchRow::builder()
|
||||
.title("Show game library")
|
||||
.subtitle(
|
||||
"Adds “Browse library…” to paired hosts — list their Steam and custom games \
|
||||
and launch one directly. No extra host setup",
|
||||
)
|
||||
.build();
|
||||
|
||||
// ---- Input ----
|
||||
let touch_row = ChoiceRow::new(
|
||||
@@ -1720,7 +1713,6 @@ pub fn show_scoped(
|
||||
echo_row.set_active(s.echo_cancel);
|
||||
hdr_row.set_active(s.hdr_enabled);
|
||||
chroma_row.set_active(s.enable_444);
|
||||
library_row.set_active(s.library_enabled);
|
||||
surround_row.set_selected(index::surround(s));
|
||||
audio_format_row.set_selected(index::audio_format(s));
|
||||
// `set_selected` never fires the changed hook, so mirror the stereo gate here — the same
|
||||
@@ -2054,12 +2046,6 @@ pub fn show_scoped(
|
||||
stats_group.add(stats_row.widget());
|
||||
general.add(&session_group);
|
||||
general.add(&stats_group);
|
||||
// The library browser is an app-level toggle for this device, not a per-profile one.
|
||||
if !profile_mode {
|
||||
let library_group = group("Library", "");
|
||||
library_group.add(&library_row);
|
||||
general.add(&library_group);
|
||||
}
|
||||
|
||||
let display = page("Display", "video-display-symbolic");
|
||||
let resolution_group = group("Resolution", "");
|
||||
@@ -2279,7 +2265,6 @@ pub fn show_scoped(
|
||||
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
|
||||
s.vsync = vsync_row.is_active();
|
||||
s.allow_vrr = vrr_row.is_active();
|
||||
s.library_enabled = library_row.is_active();
|
||||
};
|
||||
|
||||
match &active {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! `--browse [host[:port]]` — the console shell. Bare `--browse` opens the host list
|
||||
//! (discovery, pairing, settings, wake — the whole couch flow); with a target it opens
|
||||
//! straight into that host's library (the Decky per-host launch), B backing out to the
|
||||
//! list. A launches in the SAME window (no gamescope window handoff — the whole point
|
||||
//! list — one press either way, because with "Start in collections" on it is the shelf
|
||||
//! that hands over to the collections screen rather than a second screen being stacked on
|
||||
//! it. A launches in the SAME window (no gamescope window handoff — the whole point
|
||||
//! of one process), the session's end returns to the console, B at the root quits to
|
||||
//! Gaming Mode.
|
||||
//!
|
||||
|
||||
@@ -559,8 +559,6 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
set: props.set_hover.clone(),
|
||||
};
|
||||
let known = KnownHosts::load();
|
||||
// The experimental library gate ("Show game library" in Settings) — GTK/Apple parity.
|
||||
let library_enabled = ctx.settings.lock().unwrap().library_enabled;
|
||||
|
||||
// Responsive column count from the live window width (re-renders on resize): as many
|
||||
// TILE_MIN_WIDTH columns as fit the page's content width, at least one.
|
||||
@@ -763,9 +761,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
|
||||
items.push(menu_separator());
|
||||
// The library surfaces — mouse/KB page and the gamepad console UI — for
|
||||
// paired hosts only (the mgmt API needs the paired identity); the page
|
||||
// additionally sits behind the experimental toggle.
|
||||
if library_enabled && k.paired {
|
||||
// paired hosts only, because the mgmt API needs the paired identity.
|
||||
if k.paired {
|
||||
items.push(menu_item(MENU_LIBRARY));
|
||||
}
|
||||
items.push(menu_item(MENU_SPEED));
|
||||
@@ -956,8 +953,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.menu_flyout({
|
||||
let mut items = Vec::new();
|
||||
// Same gate as the primary tile's: the mgmt API needs the paired
|
||||
// identity, and the page is behind the experimental toggle.
|
||||
if library_enabled && k.paired {
|
||||
// identity, so an unpaired host has nothing to show.
|
||||
if k.paired {
|
||||
items.push(menu_item(MENU_LIBRARY));
|
||||
}
|
||||
items.push(menu_item(MENU_COPY_LINK));
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
//! `ui_library.rs`, sharing its service layer (mTLS fetch against the host's management
|
||||
//! API, pre-classified errors, the 3-worker art pipeline) and its four states
|
||||
//! (loading / error+retry / empty / grid). Reached from a paired host's "…" menu
|
||||
//! ("Browse library…", behind the Settings "Show game library" experimental toggle);
|
||||
//! picking a title starts a normal stream carrying `--launch id` — the host launches the
|
||||
//! app during the connect handshake.
|
||||
//! ("Browse library…"); picking a title starts a normal stream carrying `--launch id` —
|
||||
//! the host launches the app during the connect handshake.
|
||||
//!
|
||||
//! Poster bytes land in a small disk cache (`%LOCALAPPDATA%\punktfunk\art-cache`) and the
|
||||
//! `Image` widget loads `file:///` URIs from it — reactor's `ImageSource` has no
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(crate) enum Screen {
|
||||
/// Per-host network speed test (probe burst + recommended bitrate).
|
||||
SpeedTest,
|
||||
/// The target host's game library (poster grid; tap-to-launch) — paired hosts only,
|
||||
/// behind the "Show game library" experimental toggle.
|
||||
/// since the fetch authenticates with the pairing identity.
|
||||
Library,
|
||||
}
|
||||
|
||||
|
||||
@@ -1127,9 +1127,6 @@ pub(crate) fn settings_page(
|
||||
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
|
||||
}
|
||||
});
|
||||
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
|
||||
s.library_enabled = on
|
||||
});
|
||||
// App identity + version at the top of the About card (the WinUI Settings convention; the About
|
||||
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
|
||||
// in at compile time.
|
||||
@@ -1658,21 +1655,6 @@ pub(crate) fn settings_page(
|
||||
)],
|
||||
None,
|
||||
));
|
||||
// The library browser is an app-level toggle for this device, not a per-profile one.
|
||||
out.extend(group(
|
||||
Some("Library"),
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![described_labeled(
|
||||
"Show game library (experimental)",
|
||||
library_toggle,
|
||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
|
||||
their Steam and custom games and launch one directly. No extra host setup.",
|
||||
)]
|
||||
},
|
||||
None,
|
||||
));
|
||||
("General", out)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1283,9 +1283,6 @@ pub struct Settings {
|
||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||
pub fullscreen_on_stream: bool,
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
|
||||
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
|
||||
@@ -1322,6 +1319,16 @@ pub struct Settings {
|
||||
/// [`library_sort`](Self::library_sort).
|
||||
#[serde(default)]
|
||||
pub library_view: String,
|
||||
/// Open a host's library on its COLLECTIONS — platforms and stores as tiles — instead of
|
||||
/// the whole shelf. Presentation only, same rules as [`library_sort`](Self::library_sort).
|
||||
///
|
||||
/// Ignored by a library with fewer than two collections (see `pf-console-ui`'s
|
||||
/// `collate::worth_browsing`): a screen that opens onto a single tile is a press the user
|
||||
/// pays for nothing, so that library opens on its shelf whatever this says. Default off,
|
||||
/// like every key in this family — an existing install must not have the screen its
|
||||
/// deep links land on changed under it.
|
||||
#[serde(default)]
|
||||
pub library_collections: bool,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
|
||||
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
|
||||
@@ -1525,11 +1532,11 @@ impl Default for Settings {
|
||||
show_stats: true,
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
ui_palette: default_ui_palette(),
|
||||
reduce_motion: false,
|
||||
library_sort: String::new(),
|
||||
library_view: String::new(),
|
||||
library_collections: false,
|
||||
auto_wake: true,
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
@@ -1791,7 +1798,6 @@ mod tests {
|
||||
// Fields the old file doesn't carry take this struct's defaults.
|
||||
assert_eq!(s.forward_pad, "");
|
||||
assert!(s.fullscreen_on_stream);
|
||||
assert!(!s.library_enabled);
|
||||
// Echo cancellation post-dates every stored file: it must load ON, or an upgrade
|
||||
// would silently turn a user's echo protection off.
|
||||
assert!(s.echo_cancel);
|
||||
@@ -1819,6 +1825,25 @@ mod tests {
|
||||
assert!(!plain.contains("frob"), "{plain}");
|
||||
}
|
||||
|
||||
/// The same contract seen from the other side: a key this build RETIRED. `library_enabled`
|
||||
/// gated "Browse library…" in the GTK and WinUI shells and defaulted off, so dropping the
|
||||
/// field is what finally shows the library to everyone who never found the toggle. The
|
||||
/// stored `false` must not fail the load — that would lock a user out of their whole
|
||||
/// settings file over a setting that no longer exists — and it must survive the next
|
||||
/// whole-file write, so a downgrade still reads the value it wrote.
|
||||
#[test]
|
||||
fn settings_retired_library_key_loads_and_survives() {
|
||||
let stored = r#"{"width":1920,"height":1080,"library_enabled":false}"#;
|
||||
let s: Settings = serde_json::from_str(stored).unwrap();
|
||||
assert_eq!((s.width, s.height), (1920, 1080));
|
||||
assert_eq!(
|
||||
s.extra.get("library_enabled").and_then(|v| v.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
let out = serde_json::to_string(&s).unwrap();
|
||||
assert!(out.contains(r#""library_enabled":false"#), "{out}");
|
||||
}
|
||||
|
||||
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
||||
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
||||
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
||||
|
||||
@@ -154,7 +154,10 @@ pub(crate) struct EntranceSpec {
|
||||
/// Delay added per step of distance from the anchor.
|
||||
pub stagger: f64,
|
||||
/// Ceiling on that delay. Without it a 400-title shelf would still be arriving a
|
||||
/// minute later; with it, everything past ~6 items away starts together.
|
||||
/// minute later; with it, everything past `cap / stagger` items away starts together —
|
||||
/// five or six, for the specs below. Which is why the two move as a pair: that ratio IS
|
||||
/// the number of steps anyone ever sees fan, so raising `stagger` alone buys a wider
|
||||
/// offset across fewer steps and lands the far half of a shelf in one block.
|
||||
pub cap: f64,
|
||||
}
|
||||
|
||||
@@ -162,17 +165,28 @@ pub(crate) mod entrances {
|
||||
use super::EntranceSpec;
|
||||
|
||||
/// Carousel and coverflow cards — the loud one, and the reason this exists.
|
||||
///
|
||||
/// The stagger is measured against a card's VISIBLE life, not against `window`: the fade
|
||||
/// is over at `FADE_SHARE` and [`super::ease_out_back`] is already at 0.89 by that same
|
||||
/// point, so the last two thirds of the window is a crawl nobody can see. What is left is
|
||||
/// ~0.2 s of readable action, and a neighbour starting a little past halfway through it is
|
||||
/// what makes a strip arrive as a sequence rather than as one soft event. Judged against
|
||||
/// the whole 0.6 s a stagger half this size looks generous; judged against the 0.2 s that
|
||||
/// reads it is four frames, and since every surface here culls to a handful of items,
|
||||
/// four frames is the whole event and not the gap between two of its steps.
|
||||
pub(crate) const CARDS: EntranceSpec = EntranceSpec {
|
||||
window: 0.6,
|
||||
stagger: 0.07,
|
||||
cap: 0.42,
|
||||
stagger: 0.12,
|
||||
cap: 0.6,
|
||||
};
|
||||
/// Menu rows. Same language, deliberately quieter: a settings list that fans open like
|
||||
/// a shelf of box art is a settings list showing off.
|
||||
/// a shelf of box art is a settings list showing off. Quieter still has to be countable,
|
||||
/// though — under about three frames apart the rows read as one soft arrival rather than
|
||||
/// as a ripple — so this is a shorter offset, not an absent one.
|
||||
pub(crate) const ROWS: EntranceSpec = EntranceSpec {
|
||||
window: 0.42,
|
||||
stagger: 0.03,
|
||||
cap: 0.24,
|
||||
stagger: 0.055,
|
||||
cap: 0.33,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,9 +332,11 @@ mod tests {
|
||||
assert_eq!(e.at(3, t), e.at(7, t));
|
||||
}
|
||||
|
||||
// The cap holds: past cap/stagger = 6 steps out, everything starts at once. This is
|
||||
// what keeps a 400-title shelf from still arriving a minute later.
|
||||
assert_eq!(e.at(5 + 7, 0.3), e.at(5 + 250, 0.3));
|
||||
// The cap holds: past `cap / stagger` steps out — five, for CARDS — everything starts
|
||||
// at once. This is what keeps a 400-title shelf from still arriving a minute later.
|
||||
// Derived rather than spelled out, so re-tuning the pair re-aims the probe with it.
|
||||
let beyond = (entrances::CARDS.cap / entrances::CARDS.stagger).ceil() as usize + 1;
|
||||
assert_eq!(e.at(5 + beyond, 0.3), e.at(5 + 250, 0.3));
|
||||
|
||||
// Monotone once started, and never outside 0..=1.
|
||||
let mut last = 0.0;
|
||||
@@ -341,6 +357,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the strip reads as a SEQUENCE, which none of `entrance_envelope`'s shape
|
||||
/// properties can see: a spec with `stagger` at zero satisfies every one of them and
|
||||
/// arrives as a single soft event. Three numbers decide it, all derived from the specs
|
||||
/// so that a re-tune which quietly undoes the fan fails here rather than on a couch.
|
||||
#[test]
|
||||
fn entrance_stagger_reads_as_a_sequence() {
|
||||
// A neighbour must still be visibly behind while the anchor is halfway through its
|
||||
// FADE — `window` flatters the stagger badly, because an item is perceptually done a
|
||||
// third of the way through it, so this is measured against the part that reads.
|
||||
let separation = |spec: EntranceSpec| {
|
||||
let e = Entrance::new(spec, 5, 0.0);
|
||||
let t_mid = 0.5 * FADE_SHARE * spec.window;
|
||||
e.at(5, t_mid).fade - e.at(6, t_mid).fade
|
||||
};
|
||||
let cards = separation(entrances::CARDS);
|
||||
assert!(cards > 0.7, "CARDS neighbours arrive together: {cards}");
|
||||
let rows = separation(entrances::ROWS);
|
||||
assert!(rows > 0.5, "ROWS is quieter, not staggerless: {rows}");
|
||||
|
||||
for (name, spec, budget) in [
|
||||
("CARDS", entrances::CARDS, 1.25),
|
||||
("ROWS", entrances::ROWS, 0.8),
|
||||
] {
|
||||
// `cap / stagger` is how many steps ever fan, and every surface culls to a
|
||||
// handful of items — the coverflow shows five. Let this drop and a wider
|
||||
// `stagger` buys a bigger offset across fewer steps, which is worse, not better.
|
||||
let steps = spec.cap / spec.stagger;
|
||||
assert!(steps >= 4.0, "{name} fans only {steps} steps");
|
||||
// The other end: "too long" should be a failing test rather than an opinion. The
|
||||
// item under the cursor is untouched by both — its delay is 0 — so this budget
|
||||
// buys peripheral polish and never makes anyone wait.
|
||||
let total = spec.cap + spec.window;
|
||||
assert!(total <= budget, "{name} takes {total} s to retire");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ease-out-back must overshoot — that is the difference between a card being thrown
|
||||
/// into place and slid there — and must still land exactly on 1.0.
|
||||
#[test]
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
//! no pad at all the legend swaps to keyboard keycaps — the console stays fully
|
||||
//! drivable either way.
|
||||
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::theme::{fg, fill, stroke, Fonts, W};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use skia_safe::{Canvas, Paint, PathBuilder, Point, RRect, Rect};
|
||||
use skia_safe::{Canvas, PathBuilder, Point, RRect, Rect};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum GlyphStyle {
|
||||
@@ -43,8 +43,7 @@ pub(crate) fn pad_mark(
|
||||
k: f64,
|
||||
ink: skia_safe::Color4f,
|
||||
) {
|
||||
let mut p = Paint::new(ink, None);
|
||||
p.set_anti_alias(true);
|
||||
let mut p = fill(ink);
|
||||
if style == GlyphStyle::Keyboard {
|
||||
// A keycap: the same shape the hint bar draws for a key, at chip size.
|
||||
let h = w * 0.72;
|
||||
@@ -96,10 +95,7 @@ pub(crate) fn battery_pip(
|
||||
} else {
|
||||
crate::theme::fg(0.7)
|
||||
};
|
||||
let mut outline = Paint::new(ink, None);
|
||||
outline.set_anti_alias(true);
|
||||
outline.set_style(skia_safe::PaintStyle::Stroke);
|
||||
outline.set_stroke_width((1.2 * k) as f32);
|
||||
let outline = stroke(ink, (1.2 * k) as f32);
|
||||
let r = (2.0 * k) as f32;
|
||||
canvas.draw_rrect(RRect::new_rect_xy(cell, r, r), &outline);
|
||||
// The terminal nub, so the cell reads as a battery and not as a text field.
|
||||
@@ -114,7 +110,7 @@ pub(crate) fn battery_pip(
|
||||
r,
|
||||
r,
|
||||
),
|
||||
&Paint::new(ink, None),
|
||||
&fill(ink),
|
||||
);
|
||||
// Four segments, rounded UP so a pad with any charge left always shows at least one —
|
||||
// an empty-looking cell on a pad that still works reads as broken.
|
||||
@@ -132,7 +128,7 @@ pub(crate) fn battery_pip(
|
||||
seg_w - 0.8 * k as f32,
|
||||
cell.height() - 2.0 * pad,
|
||||
),
|
||||
&Paint::new(ink, None),
|
||||
&fill(ink),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -148,7 +144,9 @@ pub(crate) enum HintKey {
|
||||
Shoulders,
|
||||
/// ◀ ▶ — left/right adjusts the focused value.
|
||||
Adjust,
|
||||
/// ▲ — up opens the focused item's own menu.
|
||||
/// ▲ — up raises the focused item's context menu, on a screen with up to spare. Where
|
||||
/// there isn't (the library grid spends up on rows) the same menu hangs off
|
||||
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
|
||||
Up,
|
||||
Key(&'static str),
|
||||
}
|
||||
@@ -221,7 +219,7 @@ pub(crate) fn hint_bar(
|
||||
let corner = (h / 2.0 / k) as f32;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32),
|
||||
&Paint::new(crate::theme::shade(0.30), None),
|
||||
&fill(crate::theme::shade(0.30)),
|
||||
);
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
@@ -348,12 +346,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(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);
|
||||
canvas.draw_circle(center, r as f32, &ring);
|
||||
canvas.draw_circle(center, r as f32, &fill(fg(0.10)));
|
||||
canvas.draw_circle(center, r as f32, &stroke(fg(0.32), (1.2 * k) as f32));
|
||||
if style == GlyphStyle::Shapes {
|
||||
draw_ps_shape(canvas, face, center, (4.6 * k) as f32, (1.7 * k) as f32);
|
||||
} else {
|
||||
@@ -384,7 +378,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(fg(0.10), None),
|
||||
&fill(fg(0.10)),
|
||||
);
|
||||
let size = 10.0 * k;
|
||||
let tw = fonts.measure(label, W::SemiBold, size) as f64;
|
||||
@@ -410,7 +404,7 @@ fn draw_glyph(
|
||||
up.line_to((cx - tw, cyf + th));
|
||||
up.line_to((cx + tw, cyf + th));
|
||||
up.close();
|
||||
canvas.draw_path(&up.detach(), &Paint::new(fg(0.85), None));
|
||||
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
|
||||
}
|
||||
Resolved::Adjust => {
|
||||
// ◀ ▶ — two small solid triangles.
|
||||
@@ -418,7 +412,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(fg(0.85), None);
|
||||
let paint = fill(fg(0.85));
|
||||
let mut left = PathBuilder::new();
|
||||
left.move_to((cx - gap, cyf - th));
|
||||
left.line_to((cx - gap - tw, cyf));
|
||||
@@ -438,15 +432,11 @@ 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(fg(0.10), None),
|
||||
&fill(fg(0.10)),
|
||||
);
|
||||
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);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32),
|
||||
&ring,
|
||||
&stroke(fg(0.28), 1.0),
|
||||
);
|
||||
let size = 11.0 * k;
|
||||
let tw = fonts.measure(text, W::SemiBold, size) as f64;
|
||||
@@ -465,12 +455,9 @@ 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(fg(0.92), None);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width(stroke);
|
||||
fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, width: f32) {
|
||||
let mut p = stroke(fg(0.92), width);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_anti_alias(true);
|
||||
let (cx, cy) = (center.x, center.y);
|
||||
match face {
|
||||
Face::A => {
|
||||
|
||||
@@ -26,11 +26,16 @@ pub const RECEDE_SCALE: f64 = 0.24;
|
||||
pub const ROTATE_DEG: f64 = 38.0;
|
||||
/// Perspective depth for the tilt, px (CSS `perspective()` semantics).
|
||||
pub const PERSPECTIVE: f64 = 800.0;
|
||||
/// The darkening veil's max opacity (side cards stay opaque — they overlap). HALVED when
|
||||
/// the colour recede landed: this used to carry the whole "further away" reading on its
|
||||
/// own and had to be heavy for it, and is now left doing the one job a flat darkening is
|
||||
/// good at — separating cards that overlap. See `theme::recede_matrix`.
|
||||
pub const RECEDE_DIM: f64 = 0.15;
|
||||
/// The recede veil's max opacity (side cards stay opaque — they overlap). Cut twice: first
|
||||
/// when the colour recede landed and this stopped having to carry the whole "further away"
|
||||
/// reading on its own, and again when the three stacked mechanisms turned out to be summing
|
||||
/// to literal black on a receded card. It is left doing the one job a flat wash is good at —
|
||||
/// separating cards that overlap. See `theme::recede_matrix`.
|
||||
///
|
||||
/// No longer necessarily a DARKENING: the call site washes toward `theme::shade`, which is
|
||||
/// black on a dark palette and white on a pale one, so this reinforces the matrix's direction
|
||||
/// at both poles instead of greying out the lift on the six pale ones.
|
||||
pub const RECEDE_DIM: f64 = 0.10;
|
||||
/// Boundary recoil: a refused move deflects the strip this many px against the push.
|
||||
pub const BUMP_PX: f64 = 16.0;
|
||||
/// Mount entrance (see [`crate::anim::Entrance`]): a card arrives at this scale, this many
|
||||
@@ -162,34 +167,166 @@ pub enum GridDir {
|
||||
/// How many rows a shoulder press jumps.
|
||||
pub const GRID_PAGE_ROWS: i32 = 3;
|
||||
|
||||
/// Cursor arithmetic for a 2-D grid, `cols` wide.
|
||||
/// The grid's layout, in the one place both the cursor arithmetic and the renderer read it.
|
||||
///
|
||||
/// Left/right walk WITHIN a row and refuse at its ends, which is the shelf's rule and the
|
||||
/// one a thumb already knows — wrapping to the next row would make a held Right scan the
|
||||
/// whole library, and there are shoulders for that.
|
||||
/// A field of covers is not a uniform grid: the launcher prefix (design D4) is given rows of
|
||||
/// its own and the games section restarts at column 0 underneath it. While navigation did
|
||||
/// that sum for itself — index modulo `cols` — the two models agreed only when the launcher
|
||||
/// count happened to be a multiple of the column count, which with a Deck's seven columns and
|
||||
/// the usual two launchers means never. Down out of a launcher landed five columns to the
|
||||
/// right of the tile it sat under, Up out of the games band slid sideways instead of leaving
|
||||
/// it, and a row end refused mid-row. Sharing the shape is the fix; the arithmetic below is
|
||||
/// only its consequence.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GridShape {
|
||||
/// Cells per row. A RENDER fact — it depends on the window — so the shape is built from
|
||||
/// what the last frame actually drew rather than derived twice from two widths.
|
||||
pub cols: usize,
|
||||
/// How many cells there are: the FILTERED count, the one the cursor indexes.
|
||||
pub len: usize,
|
||||
/// Where the games section starts, or 0 when the field is one continuous run.
|
||||
pub split: usize,
|
||||
}
|
||||
|
||||
impl GridShape {
|
||||
/// `launchers` is the leading launcher run. The section only exists when BOTH halves do —
|
||||
/// an all-launcher or launcher-less field is a plain grid, and giving it a heading band
|
||||
/// and a gap it has no second group for would be a rule showing off.
|
||||
pub fn new(len: usize, cols: usize, launchers: usize) -> GridShape {
|
||||
let split = if launchers > 0 && launchers < len {
|
||||
launchers
|
||||
} else {
|
||||
0
|
||||
};
|
||||
GridShape { cols, len, split }
|
||||
}
|
||||
|
||||
/// The first row of the games section (meaningless when there is no split).
|
||||
pub fn split_row(&self) -> usize {
|
||||
self.split.div_ceil(self.cols.max(1))
|
||||
}
|
||||
|
||||
/// Which cell an index is drawn in.
|
||||
pub fn cell_of(&self, i: usize) -> (usize, usize) {
|
||||
let cols = self.cols.max(1);
|
||||
if self.split > 0 && i >= self.split {
|
||||
let j = i - self.split;
|
||||
(self.split_row() + j / cols, j % cols)
|
||||
} else {
|
||||
(i / cols, i % cols)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> usize {
|
||||
let cols = self.cols.max(1);
|
||||
if self.split > 0 {
|
||||
self.split_row() + (self.len - self.split).div_ceil(cols)
|
||||
} else {
|
||||
self.len.div_ceil(cols)
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of a row's first cell.
|
||||
pub fn row_start(&self, row: usize) -> usize {
|
||||
let cols = self.cols.max(1);
|
||||
if self.split > 0 && row >= self.split_row() {
|
||||
self.split + (row - self.split_row()) * cols
|
||||
} else {
|
||||
row * cols
|
||||
}
|
||||
}
|
||||
|
||||
/// How many cells a row actually holds — the launcher section's last row stops where the
|
||||
/// games section begins, and the field's last row stops at the end of the library.
|
||||
pub fn row_len(&self, row: usize) -> usize {
|
||||
let start = self.row_start(row);
|
||||
let end = if self.split > 0 && row + 1 == self.split_row() {
|
||||
self.split
|
||||
} else {
|
||||
self.len
|
||||
};
|
||||
end.saturating_sub(start).min(self.cols.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor arithmetic for the grid, against the shape the renderer is drawing.
|
||||
///
|
||||
/// Up/down move by a whole row and CLAMP into the tail row rather than refusing. A short
|
||||
/// last row is a layout accident, not a boundary the user chose to hit: pressing Down from
|
||||
/// above the gap should land on the last title, not thud.
|
||||
pub fn grid_step(cursor: i32, len: usize, cols: usize, dir: GridDir) -> StepResult {
|
||||
if len == 0 || cols == 0 {
|
||||
/// ONE rule, because an accretion of special cases is how this broke: horizontal moves walk
|
||||
/// the row and refuse at THAT ROW's true ends; vertical moves and pages change row only,
|
||||
/// carrying `col_hint` and clamping it into the target row's length. The only boundary is a
|
||||
/// move that would leave the grid.
|
||||
///
|
||||
/// Left/right refusing rather than wrapping is the shelf's rule and the one a thumb already
|
||||
/// knows — a held Right that wrapped would scan the whole library, and there are shoulders
|
||||
/// for that. Vertical moves clamp instead of refusing because a short row is a layout
|
||||
/// accident, not a boundary anyone chose to hit: Down from above the gap should land on the
|
||||
/// last title, not thud.
|
||||
///
|
||||
/// `col_hint` is the column the user last CHOSE (see [`grid_col_hint`]) rather than the one
|
||||
/// they happen to be standing in, so crossing a two-wide launcher row and coming back returns
|
||||
/// to the column the crossing started from.
|
||||
pub fn grid_step(cursor: i32, shape: GridShape, col_hint: usize, dir: GridDir) -> StepResult {
|
||||
if shape.len == 0 || shape.cols == 0 {
|
||||
return StepResult::Boundary;
|
||||
}
|
||||
let (max, cols_i) = (len as i32 - 1, cols as i32);
|
||||
let col = cursor.rem_euclid(cols_i);
|
||||
let target = match dir {
|
||||
GridDir::Left if col > 0 => cursor - 1,
|
||||
GridDir::Right if col < cols_i - 1 => cursor + 1,
|
||||
GridDir::Left | GridDir::Right => return StepResult::Boundary,
|
||||
GridDir::Up => cursor - cols_i,
|
||||
GridDir::Down => (cursor + cols_i).min(max),
|
||||
GridDir::PageBack => (cursor - cols_i * GRID_PAGE_ROWS).max(0),
|
||||
GridDir::PageForward => (cursor + cols_i * GRID_PAGE_ROWS).min(max),
|
||||
// A cursor outside the field is a stale one (the library shortened under us); reading it
|
||||
// as the nearest real cell makes the next press heal it instead of compounding it.
|
||||
let (row, col) = shape.cell_of((cursor.max(0) as usize).min(shape.len - 1));
|
||||
let moved = |i: usize| {
|
||||
if i as i32 == cursor {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
StepResult::Moved(i as i32)
|
||||
}
|
||||
};
|
||||
if target == cursor || target < 0 || target > max {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
StepResult::Moved(target)
|
||||
match dir {
|
||||
GridDir::Left => {
|
||||
if col == 0 {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
moved(shape.row_start(row) + col - 1)
|
||||
}
|
||||
}
|
||||
GridDir::Right => {
|
||||
if col + 1 >= shape.row_len(row) {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
moved(shape.row_start(row) + col + 1)
|
||||
}
|
||||
}
|
||||
GridDir::Up | GridDir::Down | GridDir::PageBack | GridDir::PageForward => {
|
||||
let (d, paging) = match dir {
|
||||
GridDir::Up => (-1, false),
|
||||
GridDir::Down => (1, false),
|
||||
GridDir::PageBack => (-GRID_PAGE_ROWS, true),
|
||||
_ => (GRID_PAGE_ROWS, true),
|
||||
};
|
||||
let target = (row as i32 + d).clamp(0, shape.rows() as i32 - 1) as usize;
|
||||
if target == row {
|
||||
// A STEP at the edge refuses. A PAGE is a "take me there", so it lands on the
|
||||
// end of the row it is already on — the same reading `step_cursor`'s clamped
|
||||
// mode has, and the one the shoulders have always had here.
|
||||
if !paging {
|
||||
return StepResult::Boundary;
|
||||
}
|
||||
let c = if d > 0 { shape.row_len(row) - 1 } else { 0 };
|
||||
return moved(shape.row_start(row) + c);
|
||||
}
|
||||
let c = col_hint.min(shape.row_len(target) - 1);
|
||||
moved(shape.row_start(target) + c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The remembered column after a move.
|
||||
///
|
||||
/// A horizontal step CHOOSES a column; a vertical one only borrows it. Keeping the rule here
|
||||
/// rather than at the call site is what stops the screen from holding a cursor and a column
|
||||
/// that disagree — the same reason the layout itself is one shared shape.
|
||||
pub fn grid_col_hint(shape: GridShape, prev: usize, dir: GridDir, landed: i32) -> usize {
|
||||
match dir {
|
||||
GridDir::Left | GridDir::Right => shape.cell_of(landed.max(0) as usize).1,
|
||||
_ => prev,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,8 +884,43 @@ impl LibraryShared {
|
||||
(s.phase.clone(), s.games.clone(), s.generation)
|
||||
}
|
||||
|
||||
pub(crate) fn drain_art(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.0.lock().unwrap().art_in.drain(..).collect()
|
||||
/// Take at most `max` newly fetched posters, leaving the rest queued.
|
||||
///
|
||||
/// Bounded because the renderer DECODES what this hands it, on the render thread. A
|
||||
/// library whose art all lands at once — the fake-library dev hook reads it off local
|
||||
/// disk, and a warm host proxy is nearly as fast — would otherwise put two hundred JPEG
|
||||
/// decodes in one frame. What stays behind costs the queue its ENCODED bytes, two orders
|
||||
/// of magnitude smaller than the raster they become.
|
||||
pub(crate) fn drain_art(&self, max: usize) -> Vec<(String, Vec<u8>)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let n = max.min(s.art_in.len());
|
||||
s.art_in.drain(..n).collect()
|
||||
}
|
||||
|
||||
/// Take at most `max` queued posters FROM `want`, leaving every other one where it is.
|
||||
///
|
||||
/// The collections screen is the caller, and it is the only screen that draws a handful
|
||||
/// of named covers rather than whatever arrives. Draining the queue wholesale there
|
||||
/// would be a quiet disaster: the bytes are pushed once per fetch and never re-sent, so
|
||||
/// everything it took and could not fan would be gone before the shelf a tile opens ever
|
||||
/// asked — a library of monograms, one screen further in. This takes the dozen covers
|
||||
/// that tile the collections and leaves the other four hundred queued for the shelf.
|
||||
pub(crate) fn take_art_for(
|
||||
&self,
|
||||
want: &std::collections::HashSet<String>,
|
||||
max: usize,
|
||||
) -> Vec<(String, Vec<u8>)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < s.art_in.len() && out.len() < max {
|
||||
if want.contains(&s.art_in[i].0) {
|
||||
out.extend(s.art_in.remove(i));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +1030,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The art queue hands the renderer a BOUNDED batch and keeps the rest, in arrival
|
||||
/// order. The renderer decodes what it takes, on the render thread, so an unbounded
|
||||
/// drain is a frame as long as the library is big — and the first frame after a fast
|
||||
/// host answered is exactly when the whole library lands at once.
|
||||
#[test]
|
||||
fn art_drains_in_bounded_batches_and_keeps_the_order() {
|
||||
let shared = LibraryShared::default();
|
||||
for i in 0..5 {
|
||||
shared.push_art(format!("g{i}"), vec![i as u8]);
|
||||
}
|
||||
let first: Vec<String> = shared.drain_art(2).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(first, ["g0", "g1"]);
|
||||
let rest: Vec<String> = shared.drain_art(9).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(
|
||||
rest,
|
||||
["g2", "g3", "g4"],
|
||||
"asking for more than is there is fine"
|
||||
);
|
||||
assert!(shared.drain_art(2).is_empty());
|
||||
}
|
||||
|
||||
/// A selective take is the collections screen's whole art story: it takes the few covers
|
||||
/// it fans and LEAVES everything else queued, in order, for the shelf that opens next.
|
||||
/// The property is what stays behind — the poster bytes are pushed once per fetch and
|
||||
/// never re-sent, so anything taken by a screen that cannot draw it is lost for good.
|
||||
#[test]
|
||||
fn a_selective_take_leaves_everything_it_did_not_ask_for() {
|
||||
let shared = LibraryShared::default();
|
||||
for i in 0..6 {
|
||||
shared.push_art(format!("g{i}"), vec![i as u8]);
|
||||
}
|
||||
let want = ["g1".to_string(), "g4".to_string(), "g9".to_string()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let took: Vec<String> = shared
|
||||
.take_art_for(&want, 8)
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
took,
|
||||
["g1", "g4"],
|
||||
"an id that never arrived is not an error"
|
||||
);
|
||||
let rest: Vec<String> = shared.drain_art(9).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(rest, ["g0", "g2", "g3", "g5"], "the rest is untouched");
|
||||
}
|
||||
|
||||
/// …and it is bounded the same way the plain drain is: the caller DECODES what it takes,
|
||||
/// on the render thread, so a library that lands all at once must not become one frame.
|
||||
#[test]
|
||||
fn a_selective_take_is_bounded_too() {
|
||||
let shared = LibraryShared::default();
|
||||
for i in 0..6 {
|
||||
shared.push_art(format!("g{i}"), vec![i as u8]);
|
||||
}
|
||||
let want: std::collections::HashSet<String> = (0..6).map(|i| format!("g{i}")).collect();
|
||||
assert_eq!(shared.take_art_for(&want, 2).len(), 2);
|
||||
assert_eq!(shared.take_art_for(&want, 2).len(), 2);
|
||||
assert_eq!(
|
||||
shared.take_art_for(&want, 9).len(),
|
||||
2,
|
||||
"and then it is empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// The GTK launcher's cursor tests, ported with the math.
|
||||
#[test]
|
||||
fn step_refuses_the_ends() {
|
||||
@@ -867,66 +1105,235 @@ mod tests {
|
||||
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// The grid's two different boundary rules, which is the whole subtlety of this
|
||||
/// function: a row END refuses (like the shelf), a short TAIL row clamps.
|
||||
/// Every shape the grid can take: the launcher-less field, a Deck's seven columns with
|
||||
/// the usual two launchers, a launcher run that fills a row and a half, the degenerate
|
||||
/// two-cell sections, and a single column.
|
||||
const SHAPES: [(usize, usize, usize); 9] = [
|
||||
(11, 4, 0),
|
||||
(40, 5, 0),
|
||||
(30, 7, 2),
|
||||
(20, 4, 6),
|
||||
(4, 4, 2),
|
||||
(9, 3, 3),
|
||||
(7, 3, 7),
|
||||
(1, 3, 1),
|
||||
(13, 1, 2),
|
||||
];
|
||||
|
||||
/// The invariant the two old layout models broke: a cell's coordinates and its row's
|
||||
/// start have to be the same statement. Rows tile `0..len` in order, no index is in two
|
||||
/// of them, and none is in none — which is what makes "the ring is where the scroll
|
||||
/// says it is" true by construction rather than by coincidence.
|
||||
#[test]
|
||||
fn grid_rows_refuse_at_their_ends_but_the_tail_row_clamps() {
|
||||
// 11 items, 4 columns: rows of 4, 4, 3.
|
||||
let (len, cols) = (11, 4);
|
||||
// Within a row.
|
||||
fn grid_rows_tile_the_field_exactly_once() {
|
||||
for (len, cols, launchers) in SHAPES {
|
||||
let s = GridShape::new(len, cols, launchers);
|
||||
let mut next = 0usize;
|
||||
for row in 0..s.rows() {
|
||||
let n = s.row_len(row);
|
||||
assert!((1..=cols).contains(&n), "{s:?} row {row} holds {n} cells");
|
||||
for col in 0..n {
|
||||
let i = s.row_start(row) + col;
|
||||
assert_eq!(i, next, "{s:?} row {row} does not follow the one above");
|
||||
assert_eq!(s.cell_of(i), (row, col), "{s:?} disagrees about index {i}");
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(next, len, "{s:?} left cells in no row at all");
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's two different boundary rules, which is the whole subtlety of the
|
||||
/// horizontal step: a row END refuses (like the shelf), and it is the row's TRUE end —
|
||||
/// not `cols`, which is a different number in every partial row and in the whole games
|
||||
/// section under a launcher prefix.
|
||||
#[test]
|
||||
fn grid_horizontal_moves_walk_the_row_and_refuse_its_true_ends() {
|
||||
for (len, cols, launchers) in SHAPES {
|
||||
let s = GridShape::new(len, cols, launchers);
|
||||
for i in 0..len {
|
||||
let (row, col) = s.cell_of(i);
|
||||
let want = |first: bool, to: i32| {
|
||||
if first {
|
||||
StepResult::Boundary
|
||||
} else {
|
||||
StepResult::Moved(to)
|
||||
}
|
||||
};
|
||||
let i = i as i32;
|
||||
// The hint must not reach a horizontal move: it is the column you WOULD
|
||||
// return to, not the one you are walking out of.
|
||||
for hint in 0..cols {
|
||||
assert_eq!(
|
||||
grid_step(i, s, hint, GridDir::Left),
|
||||
want(col == 0, i - 1),
|
||||
"{s:?} Left from {i}"
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(i, s, hint, GridDir::Right),
|
||||
want(col + 1 == s.row_len(row), i + 1),
|
||||
"{s:?} Right from {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical moves change the ROW and nothing else. A move that has a row to go to always
|
||||
/// goes there — never sideways within the row it is already in, which is what crossing
|
||||
/// the launcher/games boundary used to do — and it arrives in the remembered column,
|
||||
/// clamped into whatever that row can hold.
|
||||
#[test]
|
||||
fn grid_vertical_moves_change_row_and_carry_the_column() {
|
||||
const VERTICAL: [(GridDir, i32); 4] = [
|
||||
(GridDir::Up, -1),
|
||||
(GridDir::Down, 1),
|
||||
(GridDir::PageBack, -GRID_PAGE_ROWS),
|
||||
(GridDir::PageForward, GRID_PAGE_ROWS),
|
||||
];
|
||||
for (len, cols, launchers) in SHAPES {
|
||||
let s = GridShape::new(len, cols, launchers);
|
||||
for i in 0..len {
|
||||
let (row, _) = s.cell_of(i);
|
||||
for hint in 0..cols {
|
||||
for (dir, d) in VERTICAL {
|
||||
let want_row = (row as i32 + d).clamp(0, s.rows() as i32 - 1) as usize;
|
||||
let what = format!("{s:?} {dir:?} from {i} with hint {hint}");
|
||||
match grid_step(i as i32, s, hint, dir) {
|
||||
StepResult::Moved(to) => {
|
||||
let (r, c) = s.cell_of(to as usize);
|
||||
assert_eq!(r, want_row, "{what} landed in row {r}");
|
||||
if r != row {
|
||||
assert_eq!(c, hint.min(s.row_len(r) - 1), "{what} column");
|
||||
}
|
||||
}
|
||||
// Only the field's own edges refuse; a page already at the edge
|
||||
// still travels along the row it is on, so it refuses only from
|
||||
// that row's end.
|
||||
StepResult::Boundary => assert_eq!(want_row, row, "{what} refused"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both sections stay reachable from anywhere in the other, in a bounded number of
|
||||
/// presses. This is the user's actual complaint — a launcher row you could see but not
|
||||
/// get back into — stated as a property.
|
||||
#[test]
|
||||
fn every_row_is_reachable_by_stepping() {
|
||||
for (len, cols, launchers) in SHAPES {
|
||||
let s = GridShape::new(len, cols, launchers);
|
||||
for i in 0..len {
|
||||
for (dir, end) in [(GridDir::Up, 0), (GridDir::Down, s.rows() - 1)] {
|
||||
let mut cursor = i as i32;
|
||||
for _ in 0..=s.rows() {
|
||||
match grid_step(cursor, s, 0, dir) {
|
||||
StepResult::Moved(to) => cursor = to,
|
||||
StepResult::Boundary => break,
|
||||
}
|
||||
}
|
||||
let (row, _) = s.cell_of(cursor as usize);
|
||||
assert_eq!(row, end, "{s:?} {dir:?} from {i} stalled in row {row}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Deck, exactly: 1280×800 gives seven columns, and a host with the usual two
|
||||
/// launchers puts them alone on row 0 with the games restarting at column 0 under them.
|
||||
/// Every value here is one the uniform-grid arithmetic got wrong.
|
||||
#[test]
|
||||
fn the_launcher_row_sits_squarely_above_the_games() {
|
||||
let s = GridShape::new(30, 7, 2);
|
||||
assert_eq!(s.rows(), 5);
|
||||
assert_eq!((s.row_len(0), s.row_len(1)), (2, 7));
|
||||
// Down from a launcher lands on the cover UNDER it, not five columns to the right.
|
||||
assert_eq!(grid_step(0, s, 0, GridDir::Down), StepResult::Moved(2));
|
||||
assert_eq!(grid_step(1, s, 1, GridDir::Down), StepResult::Moved(3));
|
||||
// …and Up out of the games band leaves it, rather than sliding along it.
|
||||
assert_eq!(grid_step(2, s, 0, GridDir::Up), StepResult::Moved(0));
|
||||
assert_eq!(grid_step(3, s, 1, GridDir::Up), StepResult::Moved(1));
|
||||
assert_eq!(grid_step(6, s, 4, GridDir::Up), StepResult::Moved(1));
|
||||
// The games row's true ends are 2 and 8 — 6 and 7 are mid-row, and 8 is the end.
|
||||
assert_eq!(grid_step(6, s, 4, GridDir::Right), StepResult::Moved(7));
|
||||
assert_eq!(grid_step(7, s, 5, GridDir::Left), StepResult::Moved(6));
|
||||
assert_eq!(grid_step(8, s, 6, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(2, s, 0, GridDir::Left), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// The remembered column is what makes a crossing reversible: stepping down through a
|
||||
/// two-wide launcher row and back must return to the column you set out from, not pin
|
||||
/// you to the column the narrow row could hold.
|
||||
#[test]
|
||||
fn a_crossing_returns_to_the_column_it_started_from() {
|
||||
use GridDir::{Down, Right, Up};
|
||||
let s = GridShape::new(30, 7, 2);
|
||||
// The screen's own rule, in one place: `LibraryScreen::grid_move` steps the cursor
|
||||
// and re-reads the hint through exactly these two calls.
|
||||
let walk = |start: i32, dirs: &[GridDir]| {
|
||||
let (mut cursor, mut hint) = (start, s.cell_of(start.max(0) as usize).1);
|
||||
for &dir in dirs {
|
||||
if let StepResult::Moved(to) = grid_step(cursor, s, hint, dir) {
|
||||
hint = grid_col_hint(s, hint, dir, to);
|
||||
cursor = to;
|
||||
}
|
||||
}
|
||||
cursor
|
||||
};
|
||||
assert_eq!(walk(0, &[Down, Right, Right, Right, Right]), 6);
|
||||
// Up parks in the launcher row's only reachable column…
|
||||
assert_eq!(walk(0, &[Down, Right, Right, Right, Right, Up]), 1);
|
||||
// …and Down restores the column, twice over — a vertical move never spends it.
|
||||
assert_eq!(walk(0, &[Down, Right, Right, Right, Right, Up, Down]), 6);
|
||||
assert_eq!(
|
||||
grid_step(1, len, cols, GridDir::Right),
|
||||
StepResult::Moved(2)
|
||||
walk(0, &[Down, Right, Right, Right, Right, Up, Down, Up, Down]),
|
||||
6
|
||||
);
|
||||
assert_eq!(grid_step(2, len, cols, GridDir::Left), StepResult::Moved(1));
|
||||
// At a row's ends: refused, NOT wrapped onto the neighbouring row.
|
||||
assert_eq!(
|
||||
grid_step(3, len, cols, GridDir::Right),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(grid_step(4, len, cols, GridDir::Left), StepResult::Boundary);
|
||||
// Down from the top row lands directly below.
|
||||
assert_eq!(grid_step(1, len, cols, GridDir::Down), StepResult::Moved(5));
|
||||
// Down into the SHORT tail row clamps to the last item rather than thudding —
|
||||
// index 7 would map to 11, which does not exist.
|
||||
assert_eq!(
|
||||
grid_step(7, len, cols, GridDir::Down),
|
||||
StepResult::Moved(10)
|
||||
);
|
||||
// …and once there, Down really is the end.
|
||||
assert_eq!(
|
||||
grid_step(10, len, cols, GridDir::Down),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(grid_step(2, len, cols, GridDir::Up), StepResult::Boundary);
|
||||
assert_eq!(grid_step(6, len, cols, GridDir::Up), StepResult::Moved(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_pages_by_rows_and_lands_on_the_ends() {
|
||||
let (len, cols) = (40, 5);
|
||||
let s = GridShape::new(40, 5, 0);
|
||||
assert_eq!(
|
||||
grid_step(0, len, cols, GridDir::PageForward),
|
||||
grid_step(0, s, 0, GridDir::PageForward),
|
||||
StepResult::Moved(15)
|
||||
);
|
||||
// A page past the end lands ON the end rather than refusing — a jump is a
|
||||
// "take me there", the same reading `step_cursor`'s clamped mode has.
|
||||
assert_eq!(
|
||||
grid_step(35, len, cols, GridDir::PageForward),
|
||||
grid_step(35, s, 0, GridDir::PageForward),
|
||||
StepResult::Moved(39)
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(39, len, cols, GridDir::PageForward),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(3, len, cols, GridDir::PageBack),
|
||||
StepResult::Moved(0)
|
||||
);
|
||||
assert_eq!(
|
||||
grid_step(0, len, cols, GridDir::PageBack),
|
||||
grid_step(39, s, 4, GridDir::PageForward),
|
||||
StepResult::Boundary
|
||||
);
|
||||
assert_eq!(grid_step(3, s, 3, GridDir::PageBack), StepResult::Moved(0));
|
||||
assert_eq!(grid_step(0, s, 0, GridDir::PageBack), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// The launcher-less grid, unchanged: this is the field the old arithmetic got right,
|
||||
/// and the proof that sharing the shape did not move it.
|
||||
#[test]
|
||||
fn grid_rows_refuse_at_their_ends_but_the_tail_row_clamps() {
|
||||
// 11 items, 4 columns: rows of 4, 4, 3.
|
||||
let s = GridShape::new(11, 4, 0);
|
||||
assert_eq!(grid_step(1, s, 1, GridDir::Right), StepResult::Moved(2));
|
||||
assert_eq!(grid_step(2, s, 2, GridDir::Left), StepResult::Moved(1));
|
||||
// At a row's ends: refused, NOT wrapped onto the neighbouring row.
|
||||
assert_eq!(grid_step(3, s, 3, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(4, s, 0, GridDir::Left), StepResult::Boundary);
|
||||
// Down from the top row lands directly below.
|
||||
assert_eq!(grid_step(1, s, 1, GridDir::Down), StepResult::Moved(5));
|
||||
// Down into the SHORT tail row clamps to the last item rather than thudding —
|
||||
// column 3 does not exist down there.
|
||||
assert_eq!(grid_step(7, s, 3, GridDir::Down), StepResult::Moved(10));
|
||||
// …and once there, Down really is the end.
|
||||
assert_eq!(grid_step(10, s, 3, GridDir::Down), StepResult::Boundary);
|
||||
assert_eq!(grid_step(2, s, 2, GridDir::Up), StepResult::Boundary);
|
||||
assert_eq!(grid_step(6, s, 2, GridDir::Up), StepResult::Moved(2));
|
||||
}
|
||||
|
||||
/// The persisted view name is a FILE FORMAT, and an unknown one must land on the shelf
|
||||
@@ -945,11 +1352,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn grid_step_is_safe_on_a_degenerate_grid() {
|
||||
assert_eq!(grid_step(0, 0, 4, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(0, 5, 0, GridDir::Right), StepResult::Boundary);
|
||||
let empty = GridShape::new(0, 4, 0);
|
||||
assert_eq!(grid_step(0, empty, 0, GridDir::Right), StepResult::Boundary);
|
||||
let colless = GridShape::new(5, 0, 0);
|
||||
assert_eq!(
|
||||
grid_step(0, colless, 0, GridDir::Right),
|
||||
StepResult::Boundary
|
||||
);
|
||||
// One column: left/right are always refused, up/down still walk.
|
||||
assert_eq!(grid_step(1, 5, 1, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(1, 5, 1, GridDir::Down), StepResult::Moved(2));
|
||||
let thin = GridShape::new(5, 1, 0);
|
||||
assert_eq!(grid_step(1, thin, 0, GridDir::Right), StepResult::Boundary);
|
||||
assert_eq!(grid_step(1, thin, 0, GridDir::Down), StepResult::Moved(2));
|
||||
// A cursor the library outgrew reads as the nearest real cell, so the next press
|
||||
// heals it rather than compounding it.
|
||||
let s = GridShape::new(6, 3, 2);
|
||||
assert_eq!(grid_step(99, s, 0, GridDir::Up), StepResult::Moved(2));
|
||||
assert_eq!(grid_step(-4, s, 0, GridDir::Right), StepResult::Moved(1));
|
||||
}
|
||||
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
|
||||
|
||||
@@ -6,12 +6,25 @@
|
||||
pub(crate) mod add_host;
|
||||
pub(crate) mod collections;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod host_options;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod options;
|
||||
pub(crate) mod pair;
|
||||
pub(crate) mod pin_hosts;
|
||||
pub(crate) mod settings;
|
||||
|
||||
/// The context menu under the name the home carousel still opens it by. Home predates the
|
||||
/// generalisation and spells both the module and the type "host options"; it is the same
|
||||
/// screen, and this goes the moment that call site says [`options::OptionsScreen::for_host`].
|
||||
pub(crate) mod host_options {
|
||||
pub(crate) use super::options::OptionsScreen as HostOptionsScreen;
|
||||
|
||||
impl HostOptionsScreen {
|
||||
pub(crate) fn new(host: &crate::model::HostRow) -> HostOptionsScreen {
|
||||
HostOptionsScreen::for_host(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::glyphs::Hint;
|
||||
use crate::library::LibraryShared;
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
@@ -102,6 +115,18 @@ impl Outbox {
|
||||
pub(crate) fn replace(&mut self, screen: Screen) {
|
||||
self.nav = Some(Nav::Replace(Box::new(screen)));
|
||||
}
|
||||
|
||||
/// Raise the context menu on what the screen is focused on — the console's one door for
|
||||
/// per-item actions. The screen names the SUBJECT
|
||||
/// ([`options::OptionsScreen::for_host`], [`options::OptionsScreen::for_game`]) and the
|
||||
/// menu owns the verbs, so the next action is a row there rather than another button in a
|
||||
/// legend that already holds six.
|
||||
///
|
||||
/// Two callers: the home carousel's ▲, and the library's X. Both hand it a subject and
|
||||
/// neither names the screen variant, which is the point of the door.
|
||||
pub(crate) fn options(&mut self, menu: options::OptionsScreen) {
|
||||
self.push(Screen::HostOptions(menu));
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host's `punktfunk://` link, built from the STORE so it carries the fingerprint
|
||||
@@ -145,9 +170,10 @@ pub(crate) enum Screen {
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
/// A saved host's own actions (Wake / Copy link / Edit / Forget) — the console's
|
||||
/// answer to the touch clients' host-card overflow menu.
|
||||
HostOptions(host_options::HostOptionsScreen),
|
||||
/// The context menu: a subject and the actions that apply to it — a host's Wake / Copy
|
||||
/// link / Edit / Forget, a title's Copy link — raised by [`Outbox::options`]. It still
|
||||
/// carries the host menu's name because [`host_options`] does; both are one rename.
|
||||
HostOptions(options::OptionsScreen),
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
|
||||
@@ -8,8 +8,8 @@ use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||
use crate::theme::{fg, Fonts, EDGE_INSET, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, ROW_MAX_W};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
@@ -306,16 +306,19 @@ impl AddHostScreen {
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
fonts.centered(
|
||||
// Half of the heading's block, so it sits on the heading's column — a centred
|
||||
// sub-line under a leading title reads as belonging to the rows instead. Its width
|
||||
// is capped against the ROW column, not the screen: measured off the full width it
|
||||
// would run all the way under the controller chip.
|
||||
fonts.leading(
|
||||
canvas,
|
||||
"Hosts on this network appear automatically — add one by address for everything else.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.left) + EDGE_INSET * k,
|
||||
f64::from(rect.top) + 2.0 * k,
|
||||
f64::from(rect.width()) * 0.72,
|
||||
ROW_MAX_W * 0.72 * k,
|
||||
);
|
||||
|
||||
// While the keyboard tray is up (never on Deck) the rows squeeze above it.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,9 @@ use crate::library::{
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
|
||||
use crate::theme::{accent, fg, fill, stroke, Fonts, PanelStroke, ONLINE_GREEN, W};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, Paint, PathBuilder, Point, RRect, Rect};
|
||||
use skia_safe::{Canvas, Color4f, MaskFilter, PathBuilder, Point, RRect, Rect};
|
||||
|
||||
const TILE_W: f64 = 340.0;
|
||||
const TILE_H: f64 = 224.0;
|
||||
@@ -363,16 +363,32 @@ impl HomeScreen {
|
||||
canvas.translate((-cx as f32, -cy as f32));
|
||||
// The layer carries the fade AND the colour recede: one matrix per card, built
|
||||
// and thrown away here, which is free next to the aurora behind it.
|
||||
//
|
||||
// BOUNDED, and raised only when it has something to carry. An unbounded
|
||||
// `save_layer` allocates an offscreen the size of the whole SURFACE and composites
|
||||
// it back, so the strip was paying several full-screen offscreens a frame — one of
|
||||
// them for the focused tile, whose alpha is 1 and whose recede is 0, i.e. a layer
|
||||
// that does nothing at all. The bounds are the tile grown by the reach of what is
|
||||
// drawn INSIDE the layer: the halo (outset 4 k, sigma 10 k) and the shadow's 10 k
|
||||
// drop. Bound it to the bare tile instead and the layer would clip both away.
|
||||
let recede = 1.0 - f;
|
||||
let mut lp = Paint::default();
|
||||
lp.set_alpha_f(alpha as f32);
|
||||
if recede > 0.001 {
|
||||
lp.set_color_filter(skia_safe::color_filters::matrix_row_major(
|
||||
&crate::theme::recede_matrix(recede),
|
||||
None,
|
||||
));
|
||||
let layered = alpha < 0.999 || recede > 0.001;
|
||||
if layered {
|
||||
let mut lp = crate::theme::layer();
|
||||
lp.set_alpha_f(alpha as f32);
|
||||
if recede > 0.001 {
|
||||
lp.set_color_filter(skia_safe::color_filters::matrix_row_major(
|
||||
&crate::theme::recede_matrix(recede),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let bounds = tile.with_outset(((36.0 * k) as f32, (36.0 * k) as f32));
|
||||
canvas.save_layer(
|
||||
&skia_safe::canvas::SaveLayerRec::default()
|
||||
.bounds(&bounds)
|
||||
.paint(&lp),
|
||||
);
|
||||
}
|
||||
canvas.save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&lp));
|
||||
// The focused tile gets a palette-tinted glow UNDER its shadow — the mark that
|
||||
// reads from a sofa, where a 12 % scale difference does not.
|
||||
crate::theme::focus_halo(canvas, tile, TILE_CORNER as f32, k as f32, f as f32);
|
||||
@@ -390,18 +406,22 @@ impl HomeScreen {
|
||||
Slot::AddHost => draw_action_tile(canvas, fonts, tile, k, ActionTile::AddHost),
|
||||
Slot::Rescan => draw_action_tile(canvas, fonts, tile, k, ActionTile::Rescan),
|
||||
}
|
||||
// The veil, at HALF its old strength. It used to do the whole recede on its own
|
||||
// and had to be heavy for it; now the colour matrix above drains saturation and
|
||||
// light, and this is left doing the one job a flat darkening is actually good
|
||||
// at — separating cards that overlap.
|
||||
// The veil, the fourth and lightest of the recede's mechanisms — the transform,
|
||||
// the layer alpha and the colour matrix above already carry it, and stacking a
|
||||
// heavy darkening on top of all three is what took an unfocused tile 55 % down
|
||||
// against the aurora behind it. It goes through the scrim rather than straight
|
||||
// black so that on a pale palette it pushes the same way the matrix does instead
|
||||
// of greying back the lift.
|
||||
if f < 1.0 {
|
||||
let veil = (1.0 - f) as f32 * 0.12;
|
||||
let veil = (1.0 - f) as f32 * 0.07;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(tile, (TILE_CORNER * k) as f32, (TILE_CORNER * k) as f32),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, veil), None),
|
||||
&fill(crate::theme::shade(veil)),
|
||||
);
|
||||
}
|
||||
canvas.restore(); // layer
|
||||
if layered {
|
||||
canvas.restore(); // layer
|
||||
}
|
||||
canvas.restore(); // transform
|
||||
}
|
||||
|
||||
@@ -443,17 +463,19 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
if h.online {
|
||||
let r = 4.5 * k;
|
||||
let center = Point::new((sx - r) as f32, (t + 9.0 * k) as f32);
|
||||
let mut glow = Paint::new(
|
||||
Color4f::new(ONLINE_GREEN.r, ONLINE_GREEN.g, ONLINE_GREEN.b, 0.7),
|
||||
None,
|
||||
);
|
||||
let mut glow = fill(Color4f::new(
|
||||
ONLINE_GREEN.r,
|
||||
ONLINE_GREEN.g,
|
||||
ONLINE_GREEN.b,
|
||||
0.7,
|
||||
));
|
||||
glow.set_mask_filter(MaskFilter::blur(
|
||||
skia_safe::BlurStyle::Normal,
|
||||
(5.0 * k) as f32,
|
||||
None,
|
||||
));
|
||||
canvas.draw_circle(center, r as f32, &glow);
|
||||
canvas.draw_circle(center, r as f32, &Paint::new(ONLINE_GREEN, None));
|
||||
canvas.draw_circle(center, r as f32, &fill(ONLINE_GREEN));
|
||||
sx -= 2.0 * r + 9.0 * k;
|
||||
}
|
||||
if h.paired {
|
||||
@@ -575,22 +597,15 @@ fn draw_action_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64, kind: Ac
|
||||
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(accent(0.16), None),
|
||||
&fill(accent(0.16)),
|
||||
);
|
||||
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);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32),
|
||||
&ring,
|
||||
&stroke(accent(0.5), 1.0),
|
||||
);
|
||||
let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k);
|
||||
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);
|
||||
let mut p = stroke(accent(1.0), (3.0 * k) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_anti_alias(true);
|
||||
let r = 9.0 * k;
|
||||
match kind {
|
||||
ActionTile::AddHost => {
|
||||
@@ -628,7 +643,7 @@ fn draw_action_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64, kind: Ac
|
||||
tip.line_to(((hx + head * 0.5) as f32, (hy - head * 1.1) as f32));
|
||||
tip.line_to(((hx + head * 0.2) as f32, (hy + head * 0.7) as f32));
|
||||
tip.close();
|
||||
canvas.draw_path(&tip.detach(), &Paint::new(accent(1.0), None));
|
||||
canvas.draw_path(&tip.detach(), &fill(accent(1.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,7 +700,7 @@ fn draw_badge(
|
||||
let badge = Rect::from_xywh(x as f32, y as f32, (52.0 * k) as f32, (52.0 * k) as f32);
|
||||
let rr = RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32);
|
||||
if filled {
|
||||
let mut p = Paint::default();
|
||||
let mut p = crate::theme::shaded();
|
||||
let colors = [accent(1.0), accent(0.68)];
|
||||
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
|
||||
(
|
||||
@@ -704,12 +719,8 @@ fn draw_badge(
|
||||
));
|
||||
canvas.draw_rrect(rr, &p);
|
||||
} else {
|
||||
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);
|
||||
canvas.draw_rrect(rr, &ring);
|
||||
canvas.draw_rrect(rr, &fill(accent(0.16)));
|
||||
canvas.draw_rrect(rr, &stroke(accent(0.5), 1.0));
|
||||
}
|
||||
let ink = if filled { fg(1.0) } else { accent(1.0) };
|
||||
// Inset to ~54 % of the badge so the mark reads as a mark ON a badge rather than a
|
||||
@@ -723,7 +734,7 @@ fn draw_badge(
|
||||
side as f32,
|
||||
);
|
||||
if let Some(path) = crate::os_marks::os_mark(os, inner) {
|
||||
canvas.draw_path(&path, &Paint::new(ink, None));
|
||||
canvas.draw_path(&path, &fill(ink));
|
||||
return;
|
||||
}
|
||||
let letter: String = name
|
||||
@@ -757,12 +768,9 @@ fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) {
|
||||
(2.0 * k) as f32,
|
||||
(2.0 * k) as f32,
|
||||
),
|
||||
&Paint::new(ink, None),
|
||||
&fill(ink),
|
||||
);
|
||||
let mut p = Paint::new(ink, None);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.6 * k) as f32);
|
||||
p.set_anti_alias(true);
|
||||
let p = stroke(ink, (1.6 * k) as f32);
|
||||
let mut shackle = PathBuilder::new();
|
||||
let (cx, r) = (x + body_w / 2.0, 3.2 * k);
|
||||
shackle.move_to(((cx - r) as f32, body_top as f32));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+212
-63
@@ -1,24 +1,32 @@
|
||||
//! A saved host's own actions — Wake, Copy link, Edit…, Forget — reached with UP on its
|
||||
//! carousel tile, and the console's answer to the overflow menu every other client hangs
|
||||
//! off a host card.
|
||||
//! The console's context menu: something the user is looking at, and the actions that apply
|
||||
//! to it. One screen, any subject.
|
||||
//!
|
||||
//! Until now the console could add a host and connect to one, and that was all: a renamed
|
||||
//! machine or a host typed in with a fat-fingered address stayed wrong forever, because
|
||||
//! the only surfaces that could edit or forget one were the desktop shells. The tile is
|
||||
//! where a host is, so the tile is where its actions belong.
|
||||
//! It arrived as the saved host's own menu — Wake, Copy link, Edit…, Forget — because a
|
||||
//! renamed machine or an address typed in with a fat thumb stayed wrong forever otherwise,
|
||||
//! and the tile is where a host is, so the tile is where its actions belong. Generalising it
|
||||
//! is what stops the console growing a second idiom per verb: the library had "Copy link"
|
||||
//! wired straight to X, so one action had two shapes, and a console that answers every new
|
||||
//! action with another face button runs out of buttons long before it runs out of actions.
|
||||
//! Here a screen names a SUBJECT and the menu owns the verbs, which makes the next one — hide
|
||||
//! a title, add it to Steam, override its profile — a row in [`OptionsScreen::actions`].
|
||||
//!
|
||||
//! UP is the gesture because the carousel is horizontal — left/right are spoken for and
|
||||
//! up is free — and because the Android console already does exactly this, so the two
|
||||
//! consoles are learned once. A pinned profile card offers only Unpin: it is a shortcut,
|
||||
//! not a second host, and offering to forget the host from it would blur precisely the
|
||||
//! distinction a pin exists to draw.
|
||||
//! Which button raises it is per screen, because the screens differ; only the WORD is
|
||||
//! load-bearing, and both legends say "Options". Home's carousel is horizontal, so up is the
|
||||
//! one free direction and ▲ opens it — the gesture the Android console already teaches. The
|
||||
//! library spends up on grid rows, so there it is X, which is free precisely because copying
|
||||
//! a link stopped being a button. X also keeps the menu reachable with a mouse: the hint bar
|
||||
//! turns face-button hints into presses, and ▲ only became one of those alongside this change.
|
||||
//!
|
||||
//! A pinned profile card offers only Unpin: it is a shortcut, not a second host, and offering
|
||||
//! to forget the host from it would blur precisely the distinction a pin exists to draw.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::LibraryGame;
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use crate::theme::{fg, Fonts, EDGE_INSET, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec, ROW_MAX_W};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
@@ -33,11 +41,27 @@ enum Action {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub(crate) struct HostOptionsScreen {
|
||||
/// The row this menu was opened on, by value. Discovery rewrites the carousel every
|
||||
/// service pass; holding an index or a borrow would let the menu retarget itself onto
|
||||
/// whichever host slid into that slot, and "Forget" must never be able to do that.
|
||||
host: HostRow,
|
||||
/// What the menu was raised ON. Every difference between two menus in this file is a match on
|
||||
/// this enum and nothing else, which is what keeps a third kind of menu to a variant and a
|
||||
/// row list rather than another screen with its own list, dispatch and legend.
|
||||
pub(crate) enum Subject {
|
||||
/// A saved host's carousel tile, or a pinned profile card (the pin rides in the row).
|
||||
Host(HostRow),
|
||||
/// One title on a shelf, and the host serving it — that host's pin included, so a link
|
||||
/// taken off a pinned card's shelf still streams the way that card does.
|
||||
Game {
|
||||
host: HostRow,
|
||||
id: String,
|
||||
title: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct OptionsScreen {
|
||||
/// The subject this menu was opened on, by value. Discovery rewrites the carousel and the
|
||||
/// library re-collates its shelf while the menu is up; holding an index or a borrow would
|
||||
/// let the menu retarget itself onto whatever slid into that slot, and "Forget" must never
|
||||
/// be able to do that.
|
||||
subject: Subject,
|
||||
list: MenuList,
|
||||
/// Forget is the one action here with no undo, so the row arms on the first press and
|
||||
/// only fires on the second. The other clients forget outright; a console is driven by
|
||||
@@ -46,45 +70,66 @@ pub(crate) struct HostOptionsScreen {
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl HostOptionsScreen {
|
||||
pub(crate) fn new(host: &HostRow) -> HostOptionsScreen {
|
||||
HostOptionsScreen {
|
||||
host: host.clone(),
|
||||
impl OptionsScreen {
|
||||
pub(crate) fn for_host(host: &HostRow) -> OptionsScreen {
|
||||
OptionsScreen::on(Subject::Host(host.clone()))
|
||||
}
|
||||
|
||||
fn on(subject: Subject) -> OptionsScreen {
|
||||
OptionsScreen {
|
||||
subject,
|
||||
list: MenuList::new(),
|
||||
armed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this row worth opening a menu for at all? Only saved hosts have anything to
|
||||
/// edit or forget; a discovered-but-unsaved one is not ours to change.
|
||||
/// edit or forget; a discovered-but-unsaved one is not ours to change. A title needs no
|
||||
/// such gate — its one action fails soft, and a shelf only exists for a saved host.
|
||||
pub(crate) fn available(host: &HostRow) -> bool {
|
||||
host.saved
|
||||
}
|
||||
|
||||
/// The host every action here ultimately addresses — a title's is the one serving it.
|
||||
fn host(&self) -> &HostRow {
|
||||
match &self.subject {
|
||||
Subject::Host(h) => h,
|
||||
Subject::Game { host, .. } => host,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn title(&self) -> String {
|
||||
match &self.host.pin {
|
||||
Some(p) => format!("{} \u{b7} {}", self.host.name, p.name),
|
||||
None => self.host.name.clone(),
|
||||
match &self.subject {
|
||||
Subject::Host(h) => match &h.pin {
|
||||
Some(p) => format!("{} \u{b7} {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
Subject::Game { title, .. } => title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pinned card's key is the host's with the profile id appended past a NUL (see the
|
||||
/// service's row builder) — every command here addresses the HOST.
|
||||
fn host_key(&self) -> &str {
|
||||
self.host
|
||||
.key
|
||||
.split('\0')
|
||||
.next()
|
||||
.unwrap_or(self.host.key.as_str())
|
||||
let key = self.host().key.as_str();
|
||||
key.split('\0').next().unwrap_or(key)
|
||||
}
|
||||
|
||||
fn actions(&self) -> Vec<Action> {
|
||||
if self.host.pin.is_some() {
|
||||
let host = match &self.subject {
|
||||
Subject::Host(h) => h,
|
||||
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
|
||||
// press either, and duplicating the primary action is the one thing a menu about
|
||||
// consistency should not start life doing. Copy link leads so the cursor, which
|
||||
// starts on row 0, is already on the row nearly everyone came for.
|
||||
Subject::Game { .. } => return vec![Action::CopyLink, Action::Cancel],
|
||||
};
|
||||
if host.pin.is_some() {
|
||||
return vec![Action::Unpin, Action::CopyLink, Action::Cancel];
|
||||
}
|
||||
let mut a = Vec::new();
|
||||
// Waking a host that is already answering would just sit there counting seconds.
|
||||
if self.host.can_wake && !self.host.online {
|
||||
if host.can_wake && !host.online {
|
||||
a.push(Action::Wake);
|
||||
}
|
||||
// "Send logs" needs a paired identity (the upload authenticates with the streaming
|
||||
@@ -92,7 +137,7 @@ impl HostOptionsScreen {
|
||||
// error. This is the log-escape hatch for platforms whose own filesystem the user
|
||||
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
|
||||
// its web console next to the host's own logs.
|
||||
if self.host.paired && self.host.online {
|
||||
if host.paired && host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
a.extend([
|
||||
@@ -104,6 +149,8 @@ impl HostOptionsScreen {
|
||||
a
|
||||
}
|
||||
|
||||
/// One label per action for the whole console, so the same verb cannot end up worded two
|
||||
/// ways on two screens — which is the mess this menu exists to clear up.
|
||||
fn label(&self, a: Action) -> String {
|
||||
match a {
|
||||
Action::Wake => "Wake host".into(),
|
||||
@@ -168,6 +215,22 @@ impl HostOptionsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// This subject's `punktfunk://` link, built from the store at ACTIVATION — never at open.
|
||||
/// The store is what holds the fingerprint and stable id, and the row the menu was raised
|
||||
/// on may have left it since; a link built early would be a link built from a lie.
|
||||
fn link(&self) -> Option<String> {
|
||||
match &self.subject {
|
||||
Subject::Host(h) => crate::screens::host_link(h),
|
||||
Subject::Game { host, id, .. } => crate::screens::saved_host_link(
|
||||
&host.fp_hex,
|
||||
&host.addr,
|
||||
host.port,
|
||||
host.pin.as_ref().map(|p| p.id.as_str()),
|
||||
Some(id.as_str()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, action: Action, fx: &mut Outbox) {
|
||||
let key = self.host_key().to_string();
|
||||
match action {
|
||||
@@ -179,17 +242,18 @@ impl HostOptionsScreen {
|
||||
fx.pop();
|
||||
}
|
||||
Action::SendLogs => {
|
||||
let host = self.host();
|
||||
fx.cmds.push(ConsoleCmd::SendLogs {
|
||||
addr: self.host.addr.clone(),
|
||||
mgmt: self.host.mgmt_port,
|
||||
fp_hex: self.host.fp_hex.clone(),
|
||||
host_name: self.host.name.clone(),
|
||||
addr: host.addr.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
host_name: host.name.clone(),
|
||||
});
|
||||
fx.toast = Some(format!("Sending logs to {}\u{2026}", self.host.name));
|
||||
fx.toast = Some(format!("Sending logs to {}\u{2026}", host.name));
|
||||
fx.pop();
|
||||
}
|
||||
Action::CopyLink => {
|
||||
match crate::screens::host_link(&self.host) {
|
||||
match self.link() {
|
||||
Some(url) => {
|
||||
fx.copy = Some(url);
|
||||
fx.toast = Some("Link copied".into());
|
||||
@@ -200,16 +264,16 @@ impl HostOptionsScreen {
|
||||
fx.pop();
|
||||
}
|
||||
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
|
||||
&self.host,
|
||||
self.host(),
|
||||
))),
|
||||
Action::Forget if !self.armed => self.armed = true,
|
||||
Action::Forget => {
|
||||
fx.cmds.push(ConsoleCmd::ForgetHost { key });
|
||||
fx.toast = Some(format!("Forgot {}", self.host.name));
|
||||
fx.toast = Some(format!("Forgot {}", self.host().name));
|
||||
fx.pop();
|
||||
}
|
||||
Action::Unpin => {
|
||||
if let Some(p) = &self.host.pin {
|
||||
if let Some(p) = &self.host().pin {
|
||||
fx.cmds.push(ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id: p.id.clone(),
|
||||
@@ -230,6 +294,20 @@ impl HostOptionsScreen {
|
||||
]
|
||||
}
|
||||
|
||||
/// What this menu is FOR, in a line. Per subject, because a menu that opened from a cover
|
||||
/// and one that opened from a host tile are answering two different questions.
|
||||
fn blurb(&self) -> String {
|
||||
match &self.subject {
|
||||
Subject::Host(h) if h.pin.is_some() => {
|
||||
"This card is a shortcut to one profile on this host. Unpinning it changes \
|
||||
nothing about the host or the profile."
|
||||
.into()
|
||||
}
|
||||
Subject::Host(_) => "Manage this saved host.".into(),
|
||||
Subject::Game { host, .. } => format!("Actions for this title on {}.", host.name),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
@@ -240,23 +318,16 @@ impl HostOptionsScreen {
|
||||
_ctx: &mut Ctx,
|
||||
) {
|
||||
// The explainer line, as on Add Host — it says what this menu is FOR, and the air it
|
||||
// takes is what keeps the first row off the pinned title.
|
||||
let blurb = if self.host.pin.is_some() {
|
||||
"This card is a shortcut to one profile on this host. Unpinning it changes \
|
||||
nothing about the host or the profile."
|
||||
} else {
|
||||
"Manage this saved host."
|
||||
};
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
fonts.centered(
|
||||
// takes is what keeps the first row off the title.
|
||||
fonts.leading(
|
||||
canvas,
|
||||
blurb,
|
||||
&self.blurb(),
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.left) + EDGE_INSET * k,
|
||||
f64::from(rect.top) + 2.0 * k,
|
||||
f64::from(rect.width()) * 0.72,
|
||||
ROW_MAX_W * 0.72 * k,
|
||||
);
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
@@ -274,10 +345,23 @@ impl HostOptionsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title half of the menu — what the library's X raises, where the host half is what the
|
||||
/// home carousel's ▲ raises.
|
||||
impl OptionsScreen {
|
||||
pub(crate) fn for_game(host: &HostRow, game: &LibraryGame) -> OptionsScreen {
|
||||
OptionsScreen::on(Subject::Game {
|
||||
host: host.clone(),
|
||||
id: game.id.clone(),
|
||||
title: game.title.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::ProfileChip;
|
||||
use crate::screens::Nav;
|
||||
|
||||
fn host() -> HostRow {
|
||||
HostRow {
|
||||
@@ -310,10 +394,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn game() -> LibraryGame {
|
||||
LibraryGame {
|
||||
id: "steam:367520".into(),
|
||||
title: "Hollow Knight".into(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: "steam".into(),
|
||||
platform: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_discovered_host_has_no_menu() {
|
||||
assert!(HostOptionsScreen::available(&host()));
|
||||
assert!(!HostOptionsScreen::available(&HostRow {
|
||||
assert!(OptionsScreen::available(&host()));
|
||||
assert!(!OptionsScreen::available(&HostRow {
|
||||
saved: false,
|
||||
..host()
|
||||
}));
|
||||
@@ -321,13 +416,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn wake_is_offered_only_when_it_would_do_something() {
|
||||
let awake = HostOptionsScreen::new(&HostRow {
|
||||
let awake = OptionsScreen::for_host(&HostRow {
|
||||
can_wake: true,
|
||||
online: true,
|
||||
..host()
|
||||
});
|
||||
assert!(!awake.actions().contains(&Action::Wake));
|
||||
let asleep = HostOptionsScreen::new(&HostRow {
|
||||
let asleep = OptionsScreen::for_host(&HostRow {
|
||||
can_wake: true,
|
||||
online: false,
|
||||
..host()
|
||||
@@ -337,7 +432,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_pinned_card_cannot_forget_or_edit_the_host() {
|
||||
let s = HostOptionsScreen::new(&pinned());
|
||||
let s = OptionsScreen::for_host(&pinned());
|
||||
assert_eq!(
|
||||
s.actions(),
|
||||
vec![Action::Unpin, Action::CopyLink, Action::Cancel]
|
||||
@@ -348,7 +443,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn forget_needs_two_presses() {
|
||||
let mut s = HostOptionsScreen::new(&host());
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
let actions = s.actions();
|
||||
let i = actions.iter().position(|a| *a == Action::Forget).unwrap();
|
||||
s.list.cursor = i;
|
||||
@@ -369,7 +464,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn leaving_the_forget_row_disarms_it() {
|
||||
let mut s = HostOptionsScreen::new(&host());
|
||||
let mut s = OptionsScreen::for_host(&host());
|
||||
let actions = s.actions();
|
||||
s.armed = true;
|
||||
s.list.cursor = actions.iter().position(|a| *a == Action::Cancel).unwrap();
|
||||
@@ -387,4 +482,58 @@ mod tests {
|
||||
s.dispatch(ListMsg::None, None, &actions, &mut ctx, &mut fx);
|
||||
assert!(!s.armed, "a cursor move off the row cancels the arming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_offers_the_link_and_nothing_its_cover_already_does() {
|
||||
let s = OptionsScreen::for_game(&host(), &game());
|
||||
assert_eq!(s.actions(), vec![Action::CopyLink, Action::Cancel]);
|
||||
// The cursor starts on row 0, so the row nearly everyone opened this for is the row
|
||||
// the confirm press is already on.
|
||||
assert_eq!(s.list.cursor, 0);
|
||||
assert_eq!(s.title(), "Hollow Knight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_can_be_left_without_doing_anything() {
|
||||
for s in [
|
||||
OptionsScreen::for_host(&host()),
|
||||
OptionsScreen::for_host(&pinned()),
|
||||
OptionsScreen::for_game(&host(), &game()),
|
||||
OptionsScreen::for_game(&pinned(), &game()),
|
||||
] {
|
||||
assert_eq!(s.actions().last(), Some(&Action::Cancel));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_titles_menu_keeps_the_shelfs_whole_host_so_a_pinned_cards_profile_survives() {
|
||||
let s = OptionsScreen::for_game(&pinned(), &game());
|
||||
let Subject::Game { host, id, .. } = &s.subject else {
|
||||
panic!("built as a title menu");
|
||||
};
|
||||
assert_eq!(id, "steam:367520", "the link's launch id");
|
||||
assert_eq!(
|
||||
host.pin.as_ref().map(|p| p.id.as_str()),
|
||||
Some("prof-1"),
|
||||
"a link taken off a pinned card's shelf carries that card's profile"
|
||||
);
|
||||
// Host-addressed commands still reach past the pin's composite key.
|
||||
assert_eq!(s.host_key(), "aa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_link_always_closes_the_menu_and_says_what_happened() {
|
||||
// Whether the store still knows the host decides WHICH of the two things it says,
|
||||
// and nothing else: a menu that stayed open on failure would leave the user pressing
|
||||
// a row that can only fail again.
|
||||
for mut s in [
|
||||
OptionsScreen::for_host(&host()),
|
||||
OptionsScreen::for_game(&host(), &game()),
|
||||
] {
|
||||
let mut fx = Outbox::default();
|
||||
s.run(Action::CopyLink, &mut fx);
|
||||
assert!(matches!(fx.nav, Some(Nav::Pop)));
|
||||
assert!(fx.toast.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||
use crate::theme::{fg, Fonts, ERROR, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||
use crate::theme::{fg, Fonts, EDGE_INSET, ERROR, W};
|
||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec, ROW_MAX_W};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
@@ -339,15 +339,15 @@ impl PairScreen {
|
||||
} else {
|
||||
"Enter the PIN from the host's web console (Pairing page) or its log."
|
||||
};
|
||||
fonts.centered(
|
||||
fonts.leading(
|
||||
canvas,
|
||||
intro,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.left) + EDGE_INSET * k,
|
||||
f64::from(rect.top) + 2.0 * k,
|
||||
f64::from(rect.width()) * 0.72,
|
||||
ROW_MAX_W * 0.72 * k,
|
||||
);
|
||||
|
||||
let seat = self.keyboard.seat(self.editing.is_some() && !ctx.deck, dt);
|
||||
|
||||
@@ -63,7 +63,6 @@ enum RowId {
|
||||
Stats,
|
||||
Fullscreen,
|
||||
AutoWake,
|
||||
Library,
|
||||
/// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The
|
||||
/// backdrop behind this very row re-colours as it steps, which is the whole reason the
|
||||
/// picker lives on a screen rather than in a dialog.
|
||||
@@ -72,20 +71,25 @@ enum RowId {
|
||||
/// beside the palette row for the same reason it does: both are presentation, and the
|
||||
/// effect of stepping this one is visible on the backdrop behind it.
|
||||
ReduceMotion,
|
||||
/// How the game library arranges its titles — see `library::LibraryView`. Lives beside
|
||||
/// the other presentation rows rather than on a face button in the library itself: the
|
||||
/// library's five hints are already spoken for (A play, X copy link, Y collections,
|
||||
/// L1/R1 jump, B back), and this is a preference you set once, not a thing you toggle
|
||||
/// while browsing.
|
||||
/// How the game library arranges its titles — see `library::LibraryView`. The library
|
||||
/// changes it in place now, from the bar over its own field, which is where an
|
||||
/// arrangement you want to SEE the effect of belongs; this row stays because both
|
||||
/// surfaces write the one `library_view` key, so it is the same setting reached from a
|
||||
/// list, and it is where the explanation of the two arrangements lives.
|
||||
LibraryView,
|
||||
/// Whether opening a host's library lands on its collections rather than on the whole
|
||||
/// shelf — see `trust::Settings::library_collections`. Beside the view row because both
|
||||
/// answer "what does the library look like when I get there", and this one is the only
|
||||
/// way to reach the collections screen without the shelf's Y.
|
||||
LibraryCollections,
|
||||
}
|
||||
|
||||
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
|
||||
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles tab) but created and edited only in the desktop app (design §5.4).
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake and echo cancellation all
|
||||
// were). Still deliberately smaller than the desktop dialogs — device pickers
|
||||
// (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the trailing
|
||||
// Profiles tab) but created and edited only in the desktop app (design §5.4).
|
||||
//
|
||||
// The tab names are shared with the Apple and Android gamepad settings, so a setting is
|
||||
// found under the same word on every client. Profiles is the trailing tab and is built
|
||||
@@ -148,10 +152,10 @@ const TABS: [(&str, &[RowId]); 7] = [
|
||||
RowId::Palette,
|
||||
RowId::ReduceMotion,
|
||||
RowId::LibraryView,
|
||||
RowId::LibraryCollections,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::Library,
|
||||
],
|
||||
),
|
||||
("Profiles", &[]),
|
||||
@@ -744,6 +748,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
.label()
|
||||
.into(),
|
||||
),
|
||||
RowId::LibraryCollections => (
|
||||
None,
|
||||
"Start in collections",
|
||||
on_off(s.library_collections).into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
None,
|
||||
"Statistics overlay",
|
||||
@@ -755,7 +764,6 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
on_off(s.fullscreen_on_stream).into(),
|
||||
),
|
||||
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
|
||||
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
|
||||
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
|
||||
};
|
||||
RowSpec {
|
||||
@@ -868,7 +876,13 @@ fn detail(id: RowId) -> &'static str {
|
||||
}
|
||||
RowId::LibraryView => {
|
||||
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
|
||||
for when you already know what you are looking for."
|
||||
for when you already know what you are looking for. The library's own bar \
|
||||
switches it while you browse, along with the sort."
|
||||
}
|
||||
RowId::LibraryCollections => {
|
||||
"Opening a host's library goes straight to its collections — platforms and \
|
||||
stores as tiles — instead of the whole shelf. A library with only one \
|
||||
collection opens on the shelf as usual."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
@@ -879,7 +893,6 @@ fn detail(id: RowId) -> &'static str {
|
||||
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
|
||||
reached over a VPN, where the wake wait only adds delay."
|
||||
}
|
||||
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
|
||||
RowId::Profile(_) => {
|
||||
"Pin this profile to a host and it appears as its own card — one press \
|
||||
connects with these settings. Profiles are created and edited in the \
|
||||
@@ -1079,9 +1092,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
let at = all.iter().position(|v| *v == cur);
|
||||
step_option(at, all.len(), delta, wrap).map(|i| s.library_view = all[i].id().into())
|
||||
}
|
||||
RowId::LibraryCollections => toggle(&mut s.library_collections, delta, wrap),
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
// Navigation rows, handled before the settings path in `menu` — never a value edit.
|
||||
RowId::Profile(_) | RowId::NoProfiles => None,
|
||||
}
|
||||
@@ -1090,7 +1103,17 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
|
||||
/// The shared stepping rule: clamp when adjusting, wrap when cycling; an unknown
|
||||
/// current value snaps to the first option on any step.
|
||||
fn step_option(current: Option<usize>, len: usize, delta: i32, wrap: bool) -> Option<usize> {
|
||||
///
|
||||
/// Reachable from the sibling screens because the library's own view/sort bar edits two of
|
||||
/// the very values this screen's rows edit, and "◀ ▶ stops at the ends" is a grammar the
|
||||
/// console states once. A second copy of it there would be a second place for the boundary
|
||||
/// thud to go missing.
|
||||
pub(super) fn step_option(
|
||||
current: Option<usize>,
|
||||
len: usize,
|
||||
delta: i32,
|
||||
wrap: bool,
|
||||
) -> Option<usize> {
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -1121,7 +1144,7 @@ fn toggle(value: &mut bool, delta: i32, wrap: bool) -> Option<()> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
@@ -1155,7 +1178,12 @@ mod tests {
|
||||
/// Point the settings store at a throwaway HOME. `apply_row` rebases on the FILE
|
||||
/// before a mutating press and saves after it, so a test driving that path against the
|
||||
/// real `$HOME` would rewrite the developer's own console settings.
|
||||
fn fake_home() {
|
||||
///
|
||||
/// Shared with the library screen's tests, which drive the same `Settings::save` through
|
||||
/// the library bar: one `OnceLock` for the whole binary is what keeps the write to the
|
||||
/// environment sound, and a second copy of this would be exactly the race the SAFETY note
|
||||
/// below rules out.
|
||||
pub(crate) fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
HOME.get_or_init(|| {
|
||||
@@ -1708,7 +1736,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
// The pre-tab flat list, plus the palette row, the lossless-audio row and the
|
||||
// reduce-motion row later passes added.
|
||||
// reduce-motion row later passes added, minus the game-library toggle: this screen
|
||||
// never read it, and the library is offered on any paired host now.
|
||||
assert_eq!(seen.len(), 33, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
assert!(seen.contains(&RowId::ReduceMotion));
|
||||
@@ -1718,6 +1747,61 @@ mod tests {
|
||||
assert_eq!(TABS[PROFILES_TAB].0, "Profiles");
|
||||
}
|
||||
|
||||
/// The collections entry is a plain off-by-default toggle, and it sits directly under the
|
||||
/// row that says what the library looks like — the two are one decision read in two
|
||||
/// halves, and a user who wants the tiles goes looking beside the arrangement.
|
||||
///
|
||||
/// Off by default matters more than the placement: this key decides where a deep link
|
||||
/// lands, so an install that never opens this screen must keep the shelf it has.
|
||||
#[test]
|
||||
fn the_collections_entry_sits_with_the_library_view_and_ships_off() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
assert!(!settings.library_collections, "off by default");
|
||||
let interface = TABS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "Interface")
|
||||
.expect("the Interface tab")
|
||||
.1;
|
||||
let view = interface
|
||||
.iter()
|
||||
.position(|id| *id == RowId::LibraryView)
|
||||
.expect("the library view row");
|
||||
assert_eq!(interface.get(view + 1), Some(&RowId::LibraryCollections));
|
||||
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(
|
||||
!adjust(RowId::LibraryCollections, -1, false, &mut ctx),
|
||||
"already off = thud"
|
||||
);
|
||||
assert!(adjust(RowId::LibraryCollections, 1, false, &mut ctx));
|
||||
assert!(ctx.settings.library_collections);
|
||||
assert_eq!(
|
||||
row_spec(RowId::LibraryCollections, &ctx, &[])
|
||||
.value
|
||||
.as_deref(),
|
||||
Some("On"),
|
||||
"the row says what the key holds"
|
||||
);
|
||||
assert!(
|
||||
!adjust(RowId::LibraryCollections, 1, false, &mut ctx),
|
||||
"on = thud"
|
||||
);
|
||||
assert!(
|
||||
adjust(RowId::LibraryCollections, 1, true, &mut ctx),
|
||||
"A flips it back"
|
||||
);
|
||||
assert!(!ctx.settings.library_collections);
|
||||
}
|
||||
|
||||
/// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into
|
||||
/// another section doesn't lose your place.
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,7 @@ use anyhow::{anyhow, Result};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
||||
use pf_client_core::trust;
|
||||
use pf_presenter::overlay::OverlayAction;
|
||||
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
||||
use skia_safe::{Canvas, Color4f, Data, Rect, RuntimeEffect};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -79,8 +79,10 @@ enum Motion {
|
||||
/// undone. Only a push is ever retargeted to 0.0 (see [`Shell::nav_back`]).
|
||||
target: f64,
|
||||
kind: NavKind,
|
||||
/// The screen being dismissed. `Some` only for a pop — a push leaves its parent on
|
||||
/// the stack, so there is nothing to carry.
|
||||
/// The screen leaving the stack, when it is no longer ON the stack to be drawn from.
|
||||
/// A pop always carries one. A plain push never does — its parent stays put and the
|
||||
/// renderer finds it at `n - 2` — but a REPLACE does, because the screen it swapped
|
||||
/// out is gone and `n - 2` is that screen's parent, a level too far.
|
||||
leaving: Option<Box<Screen>>,
|
||||
},
|
||||
}
|
||||
@@ -451,6 +453,39 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.collections_handover();
|
||||
}
|
||||
|
||||
/// "Start in collections": a shelf that has just learned it holds more than one collection
|
||||
/// stands aside for the collections screen.
|
||||
///
|
||||
/// It lives here rather than in the screen because a screen cannot replace ITSELF — the
|
||||
/// decision needs the library model and the settings, which the shelf has, but the swap
|
||||
/// needs the stack, which only the shell has. The shelf answers the question and hands
|
||||
/// back a screen; this puts it where the shelf was standing.
|
||||
///
|
||||
/// Guarded on a settled transition. Mid-flight the stack's top is not yet what the user
|
||||
/// is looking at, and swapping under a push the user has already reversed with B would
|
||||
/// land them on the collections of a host they just backed out of.
|
||||
fn collections_handover(&mut self) {
|
||||
if !matches!(self.motion, Motion::None) {
|
||||
return;
|
||||
}
|
||||
// Field borrows rather than clones: this runs every frame for the life of the shelf,
|
||||
// and `Settings` is a struct of owned Strings. `stack` is borrowed mutably while
|
||||
// `library` and `settings` are borrowed shared — disjoint fields, so the shelf can
|
||||
// read both while it is itself being held.
|
||||
let upgraded = match self.stack.last_mut() {
|
||||
Some(Screen::Library(shelf)) => {
|
||||
shelf.collections_upgrade(&self.library, &self.settings)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(screen) = upgraded {
|
||||
let n = self.stack.len();
|
||||
self.stack[n - 1] = Screen::Collections(screen);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_connect(&mut self, intent: ConnectIntent) {
|
||||
@@ -573,7 +608,7 @@ impl Shell {
|
||||
}
|
||||
if p.press() {
|
||||
if let Some((key, _)) = self.hint_rects.iter().find(|(_, r)| p.hits(*r)) {
|
||||
// Only the face-button hints are actions. Shoulders and Adjust describe a
|
||||
// A hint is clickable when it names an ACTION. Shoulders and Adjust name a
|
||||
// DIRECTION, and the thing they steer — the tab strip, a row's value — is
|
||||
// already under the pointer's finger; inventing a side for a click here
|
||||
// would just be a worse way to press what it can already press.
|
||||
@@ -582,6 +617,11 @@ impl Shell {
|
||||
crate::glyphs::HintKey::Back => Some(MenuEvent::Back),
|
||||
crate::glyphs::HintKey::Secondary => Some(MenuEvent::Secondary),
|
||||
crate::glyphs::HintKey::Tertiary => Some(MenuEvent::Tertiary),
|
||||
// ▲ is drawn as a direction and read as one, but it steers nothing: the
|
||||
// only screen that publishes it is the home carousel, where up is not
|
||||
// navigation but "open this tile's menu". Without this the context menu —
|
||||
// and with it the only way to copy a host's link — is pad-only.
|
||||
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
@@ -730,11 +770,19 @@ impl Shell {
|
||||
Nav::Replace(screen) => {
|
||||
// Swap under the SAME push choreography: the outgoing screen is dropped
|
||||
// rather than parked, so Back from the incoming one lands where the
|
||||
// replaced screen was reached from — and so does a Back that REVERSES this
|
||||
// push, which pops to the same parent.
|
||||
self.stack.pop();
|
||||
// replaced screen was reached from.
|
||||
//
|
||||
// It is CARRIED through the transition rather than dropped on the spot,
|
||||
// which is the whole difference between this reading right and reading
|
||||
// wrong. A push paints the screen BENEATH the incoming one as its receding
|
||||
// layer; drop the replaced screen first and that is its parent, so choosing
|
||||
// "Edit…" in a host's menu animated the editor in over HOME — the host list
|
||||
// flashing up for the length of the transition, as if the menu had been
|
||||
// dismissed and something else opened. Handing it over as the leaving layer
|
||||
// means the menu itself recedes, which is what actually happened.
|
||||
let leaving = self.stack.pop().map(Box::new);
|
||||
self.stack.push(*screen);
|
||||
self.begin_nav(NavKind::Push, None);
|
||||
self.begin_nav(NavKind::Push, leaving);
|
||||
}
|
||||
Nav::Pop => {
|
||||
if self.stack.len() > 1 {
|
||||
@@ -827,13 +875,25 @@ impl Shell {
|
||||
|
||||
/// A settled transition's bookkeeping. Called once the spring has landed on its target.
|
||||
fn finish_nav(&mut self) {
|
||||
if let Motion::Nav { kind, target, .. } = &self.motion {
|
||||
if let Motion::Nav {
|
||||
kind,
|
||||
target,
|
||||
leaving,
|
||||
..
|
||||
} = &mut self.motion
|
||||
{
|
||||
// A push that was reversed mid-flight never happened: take its screen back off.
|
||||
// For a `Replace` this lands on the same parent a settled Back would have, so
|
||||
// the two agree. Guarded on length because the root must never be popped here —
|
||||
// `nav_back` refuses to reverse there, and this is the belt to that's braces.
|
||||
// Guarded on length because the root must never be popped here — `nav_back`
|
||||
// refuses to reverse there, and this is the belt to that's braces.
|
||||
if *kind == NavKind::Push && *target == 0.0 && self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
// A reversed REPLACE puts back what it swapped out. The transition showed
|
||||
// that screen receding and then coming home again, so landing anywhere else
|
||||
// would contradict what was on glass — and "undo that navigation" means the
|
||||
// menu you were standing in, not the screen one level further out.
|
||||
if let Some(back) = leaving.take() {
|
||||
self.stack.push(*back);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A completed pop drops the screen it was carrying, exactly as before.
|
||||
@@ -883,7 +943,7 @@ impl Shell {
|
||||
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();
|
||||
let mut paint = crate::theme::shaded();
|
||||
paint.set_shader(shader);
|
||||
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
use crate::anim::{approach, springs};
|
||||
use crate::glyphs::{hint_bar, Hint, HintKey};
|
||||
use crate::theme::{fg, Fonts, PanelStroke, W};
|
||||
use skia_safe::{gradient, Canvas, Color4f, Paint, PathBuilder, Point, Rect, TileMode};
|
||||
use crate::theme::{fg, fill, Fonts, PanelStroke, W};
|
||||
use skia_safe::{gradient, Canvas, Color4f, PathBuilder, Point, Rect, TileMode};
|
||||
|
||||
use super::{Shell, ToastMark, BOTTOM_BAND};
|
||||
|
||||
/// The toast's leading mark, centred on `(cx, cy)` in a ~13 dp box.
|
||||
fn draw_toast_mark(canvas: &Canvas, mark: ToastMark, cx: f64, cy: f64, k: f64, ink: Color4f) {
|
||||
let mut p = Paint::new(ink, None);
|
||||
p.set_anti_alias(true);
|
||||
let mut p = fill(ink);
|
||||
match mark {
|
||||
ToastMark::Dot => {
|
||||
canvas.draw_circle((cx as f32, cy as f32), (3.4 * k) as f32, &p);
|
||||
@@ -162,17 +161,21 @@ impl Shell {
|
||||
let size = 13.0 * k;
|
||||
let tw = f64::from(fonts.measure(&toast.text, W::Medium, size));
|
||||
let (pad_x, bh) = (16.0 * k, 34.0 * k);
|
||||
// Leading run: hairline, air, mark, air — then the text.
|
||||
let (hair_w, mark_w, gap) = (3.0 * k, 13.0 * k, 9.0 * k);
|
||||
let lead = hair_w + gap + mark_w + gap;
|
||||
let bw = lead + tw + 2.0 * pad_x;
|
||||
// Leading run: the kind mark, air, then the text. The mark carries the kind by
|
||||
// itself — a hairline in the same tint used to stand beside it, saying the same
|
||||
// thing twice and reading as a rendering seam rather than as meaning. Its pad is
|
||||
// shy of `pad_x` because nothing drawn in the 13 dp box fills it, and an equal pad
|
||||
// leaves the pill visibly left-heavy in air.
|
||||
let (mark_pad, mark_w, gap) = (13.0 * k, 13.0 * k, 9.0 * k);
|
||||
let lead = mark_pad + mark_w + gap;
|
||||
let bw = lead + tw + pad_x;
|
||||
let bx = (w - bw) / 2.0;
|
||||
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
|
||||
canvas.save_layer_alpha_f(None, alpha);
|
||||
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(crate::theme::shade(0.6), None),
|
||||
&fill(crate::theme::shade(0.6)),
|
||||
);
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
@@ -183,29 +186,12 @@ impl Shell {
|
||||
k as f32,
|
||||
);
|
||||
let cy = by + bh / 2.0;
|
||||
// The hairline: the kind, readable from a couch without reading the words.
|
||||
let hair = Rect::from_xywh(
|
||||
(bx + pad_x) as f32,
|
||||
(cy - 8.0 * k) as f32,
|
||||
hair_w as f32,
|
||||
(16.0 * k) as f32,
|
||||
);
|
||||
canvas.draw_rrect(
|
||||
skia_safe::RRect::new_rect_xy(hair, (hair_w / 2.0) as f32, (hair_w / 2.0) as f32),
|
||||
&Paint::new(tint, None),
|
||||
);
|
||||
draw_toast_mark(
|
||||
canvas,
|
||||
mark,
|
||||
bx + pad_x + hair_w + gap + mark_w / 2.0,
|
||||
cy,
|
||||
k,
|
||||
tint,
|
||||
);
|
||||
// Tinted by kind, so a toast is readable from a couch without reading the words.
|
||||
draw_toast_mark(canvas, mark, bx + mark_pad + mark_w / 2.0, cy, k, tint);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&toast.text,
|
||||
bx + pad_x + lead,
|
||||
bx + lead,
|
||||
cy + size * 0.36,
|
||||
W::Medium,
|
||||
size,
|
||||
@@ -242,7 +228,7 @@ impl Shell {
|
||||
self.draw_aurora(canvas, w, h, t, 0.0);
|
||||
// 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();
|
||||
let mut vignette = crate::theme::shaded();
|
||||
let shades = [crate::theme::shade(0.5), crate::theme::shade(0.0)];
|
||||
vignette.set_shader(gradient::shaders::radial_gradient(
|
||||
(
|
||||
|
||||
@@ -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::{fg, Fonts, PanelStroke, W};
|
||||
use crate::theme::{fg, Fonts, PanelStroke, EDGE_INSET, W};
|
||||
use pf_client_core::gamepad::PadInfo;
|
||||
use pf_client_core::trust;
|
||||
use skia_safe::{Canvas, Rect};
|
||||
@@ -81,6 +81,23 @@ impl Shell {
|
||||
w as f32,
|
||||
(h - BOTTOM_BAND * k) as f32,
|
||||
);
|
||||
// How much room the heading has before it reaches the controller chip. The chip is
|
||||
// painted last so it sits above every layer, but its geometry is known now — `chip`
|
||||
// and `pads` are both set above — and the heading needs it: centred, the title had
|
||||
// the whole width to spread symmetrically into, where left-aligned it runs AT the
|
||||
// chip. The 12 is the gap Apple keeps between the two; the floor keeps a
|
||||
// pathologically long chip string from squeezing the title to nothing.
|
||||
let title_max_w = {
|
||||
let chip_w = self.chip.as_ref().map_or(0.0, |c| {
|
||||
chip_width(
|
||||
fonts,
|
||||
c,
|
||||
self.pads.first().is_some_and(|p| p.battery.is_some()),
|
||||
k,
|
||||
)
|
||||
});
|
||||
(w - 2.0 * EDGE_INSET * k - chip_w - 12.0 * k).max(w * 0.35)
|
||||
};
|
||||
// One paint recipe per layer: (alpha, slide, scale). Everything below borrows
|
||||
// disjoint fields of `self` per call, so the borrow checker stays happy.
|
||||
let mut env = LayerEnv {
|
||||
@@ -89,6 +106,7 @@ impl Shell {
|
||||
h,
|
||||
content,
|
||||
k,
|
||||
title_max_w,
|
||||
dt,
|
||||
fonts,
|
||||
hosts: &self.hosts,
|
||||
@@ -119,6 +137,7 @@ impl Shell {
|
||||
(
|
||||
Motion::Nav {
|
||||
kind: NavKind::Push,
|
||||
leaving,
|
||||
..
|
||||
},
|
||||
Some(p),
|
||||
@@ -126,15 +145,18 @@ impl Shell {
|
||||
let n = self.stack.len();
|
||||
let enter_scale = zoom(NAV_ENTER_SCALE + (1.0 - NAV_ENTER_SCALE) * p);
|
||||
let enter_slide = slide(NAV_SLIDE_DP * k * (1.0 - p));
|
||||
let recede = zoom(1.0 - (1.0 - NAV_EXIT_SCALE) * p);
|
||||
// Outgoing recedes underneath…
|
||||
if n >= 2 {
|
||||
if let Some(replaced) = leaving.as_mut() {
|
||||
// A REPLACE carries the screen it swapped out, because that screen is no
|
||||
// longer on the stack to be found under the incoming one. Painting the
|
||||
// stack's own n-2 here would recede the replaced screen's PARENT, which
|
||||
// is how choosing "Edit…" in a host menu used to flash the host list.
|
||||
env.paint(replaced.as_mut(), 1.0 - p, 0.0, recede);
|
||||
env.paint(&mut self.stack[n - 1], p, enter_slide, enter_scale);
|
||||
} else if n >= 2 {
|
||||
let (below, top) = self.stack.split_at_mut(n - 1);
|
||||
env.paint(
|
||||
&mut below[n - 2],
|
||||
1.0 - p,
|
||||
0.0,
|
||||
zoom(1.0 - (1.0 - NAV_EXIT_SCALE) * p),
|
||||
);
|
||||
env.paint(&mut below[n - 2], 1.0 - p, 0.0, recede);
|
||||
// …while the incoming slides up out of a fade.
|
||||
env.paint(&mut top[0], p, enter_slide, enter_scale);
|
||||
} else {
|
||||
@@ -174,17 +196,9 @@ impl Shell {
|
||||
let tw = f64::from(fonts.measure(chip, W::Medium, size));
|
||||
let (bh, pad_x, gap) = (24.0 * k, 12.0 * k, 8.0 * k);
|
||||
let mark_w = 15.0 * k;
|
||||
// The battery only takes room when there IS one: a wired pad, a Steam virtual
|
||||
// pad and "no controller" all report nothing, and the chip must not carry a
|
||||
// gap where their charge would have been.
|
||||
let battery = self.pads.first().and_then(|p| p.battery);
|
||||
let pip_w = if battery.is_some() {
|
||||
22.0 * k + gap
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let bw = pad_x + mark_w + gap + tw + pip_w + pad_x;
|
||||
let bx = w - 24.0 * k - bw;
|
||||
let bw = chip_width(fonts, chip, battery.is_some(), k);
|
||||
let bx = w - EDGE_INSET * k - bw;
|
||||
let top = 18.0 * k;
|
||||
let rect = Rect::from_xywh(bx as f32, top as f32, bw as f32, bh as f32);
|
||||
crate::theme::panel(
|
||||
@@ -222,6 +236,21 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// The controller chip's drawn width, device px. Its own function because two things need
|
||||
/// it — the chip itself, and the heading, which is left-aligned now and so has to stop short
|
||||
/// of it. A second copy of this arithmetic is a title that slides under the chip the day
|
||||
/// someone adds a field to it.
|
||||
///
|
||||
/// The battery only takes room when there IS one: a wired pad, a Steam virtual pad and "no
|
||||
/// controller" all report nothing, and the chip must not carry a gap where their charge
|
||||
/// would have been.
|
||||
fn chip_width(fonts: &Fonts, chip: &str, has_battery: bool, k: f64) -> f64 {
|
||||
let tw = f64::from(fonts.measure(chip, W::Medium, 12.0 * k));
|
||||
let (pad_x, gap, mark_w) = (12.0 * k, 8.0 * k, 15.0 * k);
|
||||
let pip_w = if has_battery { 22.0 * k + gap } else { 0.0 };
|
||||
pad_x + mark_w + gap + tw + pip_w + pad_x
|
||||
}
|
||||
|
||||
/// Everything one screen layer needs to paint — bundled so the transition arms stay
|
||||
/// readable and each `paint` call borrows `Shell` fields disjointly.
|
||||
struct LayerEnv<'a> {
|
||||
@@ -230,6 +259,8 @@ struct LayerEnv<'a> {
|
||||
h: f64,
|
||||
content: Rect,
|
||||
k: f64,
|
||||
/// The heading's width budget — everything left of the controller chip. See `Shell::render`.
|
||||
title_max_w: f64,
|
||||
dt: f64,
|
||||
fonts: &'a Fonts,
|
||||
hosts: &'a [HostRow],
|
||||
@@ -272,15 +303,15 @@ impl LayerEnv<'_> {
|
||||
device_name: self.device_name,
|
||||
t: self.t,
|
||||
};
|
||||
self.fonts.centered(
|
||||
self.fonts.heading(
|
||||
canvas,
|
||||
&screen.title(&ctx),
|
||||
W::Bold,
|
||||
30.0 * self.k,
|
||||
fg(1.0),
|
||||
self.w / 2.0,
|
||||
EDGE_INSET * self.k,
|
||||
18.0 * self.k,
|
||||
self.w * 0.7,
|
||||
self.title_max_w,
|
||||
);
|
||||
screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx);
|
||||
let rects = if self.show_hints {
|
||||
|
||||
@@ -382,6 +382,59 @@ fn a_secondary_press_goes_back() {
|
||||
));
|
||||
}
|
||||
|
||||
/// A REPLACE recedes the screen it replaced, not that screen's parent.
|
||||
///
|
||||
/// Reported from a Deck: choosing "Edit…" in a host's menu flashed the host LIST for the
|
||||
/// length of the transition before the editor arrived. The cause is that a push paints the
|
||||
/// screen beneath the incoming one as its receding layer, while a replace had already popped
|
||||
/// and dropped the screen being swapped out — so "beneath" was the menu's parent, one level
|
||||
/// too far, and the transition animated the editor in over Home.
|
||||
///
|
||||
/// Asserted on the carried screen rather than on pixels: the defect is entirely a question of
|
||||
/// WHICH screen the motion holds, and a frame diff would pin the particular look of a
|
||||
/// transition instead of the thing that was wrong with it.
|
||||
#[test]
|
||||
fn a_replace_carries_the_screen_it_replaced() {
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Up));
|
||||
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
|
||||
finish_motion(&mut s);
|
||||
|
||||
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
|
||||
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
|
||||
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
|
||||
// quietly landing this test's Confirm on "Forget".
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Down));
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::AddHost(_))),
|
||||
"Edit… opens the host editor"
|
||||
);
|
||||
assert_eq!(s.stack.len(), 2, "the menu was swapped out, not stacked on");
|
||||
match &s.motion {
|
||||
Motion::Nav {
|
||||
kind: NavKind::Push,
|
||||
leaving: Some(carried),
|
||||
..
|
||||
} => assert!(
|
||||
matches!(carried.as_ref(), Screen::HostOptions(_)),
|
||||
"the receding layer must be the MENU; carrying nothing leaves the renderer to \
|
||||
recede the menu's parent, which is the reported flash"
|
||||
),
|
||||
_ => panic!("a replace must be a push CARRYING its predecessor"),
|
||||
}
|
||||
|
||||
// …and reversing it puts the menu back, because that is the screen the user watched
|
||||
// recede and then return.
|
||||
s.handle_menu(MenuEvent::Back);
|
||||
finish_motion(&mut s);
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::HostOptions(_))),
|
||||
"a reversed replace lands where the user actually was"
|
||||
);
|
||||
}
|
||||
|
||||
/// Up on a saved tile opens that host's menu; a discovered-but-unsaved one has none.
|
||||
#[test]
|
||||
fn up_opens_host_options_for_saved_tiles_only() {
|
||||
@@ -606,6 +659,84 @@ fn mixed_library(library: &LibraryShared) {
|
||||
]);
|
||||
}
|
||||
|
||||
/// "Start in collections" actually starts in collections — asserted on the SHELL, because
|
||||
/// the shelf was never the part that was broken.
|
||||
///
|
||||
/// The handover shipped dead: `LibraryScreen::collections_upgrade` was written, documented and
|
||||
/// unit-tested for its DECISION, and then nothing ever called it. It carried an
|
||||
/// `#[allow(dead_code)]`, which is precisely what stopped the compiler from saying so, and the
|
||||
/// shelf's own tests passed throughout because they called it directly. The setting was on,
|
||||
/// the shelf agreed it should stand aside, and the library opened on the shelf anyway.
|
||||
///
|
||||
/// So this drives `Shell::sync` and asserts on the STACK. A screen cannot replace itself —
|
||||
/// only the shell owns the stack — so the shell is where the wiring has to be witnessed.
|
||||
#[test]
|
||||
fn the_setting_hands_a_multi_platform_library_over_to_collections() {
|
||||
let games: Vec<crate::library::LibraryGame> = platform_games();
|
||||
for (want_collections, enabled) in [(true, true), (false, false)] {
|
||||
let (mut s, _console, library) = shell(vec![
|
||||
Screen::Home(HomeScreen::new()),
|
||||
Screen::Library(LibraryScreen::new(&hosts()[0])),
|
||||
]);
|
||||
s.settings.library_collections = enabled;
|
||||
|
||||
// The shelf must see its OWN fetch go out before it will read the model: a library
|
||||
// that is Ready before the fetch is the PREVIOUS host's, still sitting in the shared
|
||||
// model. A default library is Loading, so this frame is that proof.
|
||||
s.sync();
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::Library(_))),
|
||||
"nothing to hand over to while the fetch is still out"
|
||||
);
|
||||
|
||||
library.set_games(games.clone());
|
||||
s.sync();
|
||||
|
||||
if want_collections {
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::Collections(_))),
|
||||
"the setting is on and the library has four platforms — it must open on them"
|
||||
);
|
||||
assert_eq!(
|
||||
s.stack.len(),
|
||||
2,
|
||||
"it REPLACES the shelf, never stacks on it"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::Library(_))),
|
||||
"with the setting off the library opens on its shelf"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// …and a library with only ONE collection opens on its shelf whatever the setting says,
|
||||
/// because a collections screen listing a single tile is a press that buys nothing.
|
||||
#[test]
|
||||
fn one_collection_is_not_worth_a_screen() {
|
||||
let (mut s, _console, library) = shell(vec![
|
||||
Screen::Home(HomeScreen::new()),
|
||||
Screen::Library(LibraryScreen::new(&hosts()[0])),
|
||||
]);
|
||||
s.settings.library_collections = true;
|
||||
s.sync();
|
||||
library.set_games(
|
||||
platform_games()
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
g.platform = Some("PlayStation 3".into());
|
||||
g
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
s.sync();
|
||||
assert!(
|
||||
matches!(s.stack.last(), Some(Screen::Library(_))),
|
||||
"one platform is not a set of collections"
|
||||
);
|
||||
}
|
||||
|
||||
/// The user's flow, verbatim: group by platform, walk the platforms, pick PS3, see its
|
||||
/// games — and get back out again. This is the whole point of Part C, so it is asserted
|
||||
/// end to end rather than in pieces.
|
||||
@@ -960,7 +1091,45 @@ fn dump_console_screens() {
|
||||
};
|
||||
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||
s2.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||
dump(&mut s2, 40, 8, "07-library", true);
|
||||
// 80 frames, not 40: this shelf carries no art, so it takes the entrance's 400 ms
|
||||
// art-wait deadline, and until that expires the screen is deliberately the loading
|
||||
// spinner. At 40×8 ms the dump could finish inside the wait and shoot the SPINNER while
|
||||
// claiming to be the coverflow — a screenshot that lies is worse than a missing one.
|
||||
dump(&mut s2, 80, 8, "07-library", true);
|
||||
|
||||
// Collections, the drill-in, on a library that actually has PLATFORMS — the scene above
|
||||
// has none, so collating it would yield one group and witness nothing.
|
||||
//
|
||||
// The order below is load-bearing, and the reason there was no collections scene until a
|
||||
// tile redesign needed one. `adopt_art` is a ONE-SHOT snapshot taken the moment Y is
|
||||
// pressed, so art has to be pushed AND the shelf given frames to decode it BEFORE the
|
||||
// press. Press first and every tile renders its monogram, and a deck of covers looks
|
||||
// exactly like a deck that was never built.
|
||||
//
|
||||
// That same ordering — art before the game list — is what the fake-library dev hook does,
|
||||
// and it MASKS the shelf's entrance defect (art is already decoded on the first Ready
|
||||
// frame, so the entrance arms immediately). These scenes are evidence about the collection
|
||||
// TILE and nothing else; do not read them as saying the entrance is well.
|
||||
for (name, palette) in [
|
||||
("07b-collections", "violet"),
|
||||
("07b-collections-mint", "mint"),
|
||||
] {
|
||||
let (mut s3, _c3, _l3) = collections_shell();
|
||||
s3.settings.ui_palette = palette.to_string();
|
||||
dump(&mut s3, 12, 8, &format!("_{name}-decode"), true);
|
||||
s3.handle_menu(MenuEvent::Secondary);
|
||||
dump(&mut s3, 40, 8, name, true);
|
||||
}
|
||||
// …and the same screen with NOTHING decoded: the ghost slots and the monogram badge, which
|
||||
// is the permanent look of a platform full of art-less ROM entries rather than a loading
|
||||
// state. Pale, because that is where a hardcoded face strands its own initials.
|
||||
{
|
||||
let (mut s3, _c3, _l3) = collections_shell_no_art();
|
||||
s3.settings.ui_palette = "mint".to_string();
|
||||
dump(&mut s3, 12, 8, "_noart-settle", true);
|
||||
s3.handle_menu(MenuEvent::Secondary);
|
||||
dump(&mut s3, 40, 8, "07b-collections-noart", true);
|
||||
}
|
||||
|
||||
// The wake and connecting overlays + a toast.
|
||||
console.set_wake(Some(WakeStatus {
|
||||
@@ -988,3 +1157,347 @@ fn dump_console_screens() {
|
||||
s.session_failed("Connection timed out");
|
||||
dump(&mut s, 10, 8, "10-toast", true);
|
||||
}
|
||||
|
||||
/// A 2:3 poster, PNG-encoded, in a colour derived from `seed`.
|
||||
///
|
||||
/// Real encoded bytes rather than a stub, because the thing under test is the decode path:
|
||||
/// `LibraryScreen` feeds these to `Image::from_encoded`, and a shape that fails to decode is
|
||||
/// indistinguishable in a screenshot from a tile that chose to draw no cover.
|
||||
fn poster_png(seed: usize) -> Vec<u8> {
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((60, 90)).unwrap();
|
||||
let hue = [
|
||||
(0.85, 0.30, 0.35),
|
||||
(0.30, 0.55, 0.85),
|
||||
(0.35, 0.75, 0.45),
|
||||
(0.85, 0.65, 0.25),
|
||||
][seed % 4];
|
||||
surface
|
||||
.canvas()
|
||||
.clear(skia_safe::Color4f::new(hue.0, hue.1, hue.2, 1.0));
|
||||
// A darker band across the lower third, so a cover is visibly ORIENTED — a flat colour
|
||||
// would hide a cover drawn upside-down or with its aspect wrong.
|
||||
surface.canvas().draw_rect(
|
||||
skia_safe::Rect::from_xywh(0.0, 62.0, 60.0, 28.0),
|
||||
&crate::theme::fill(skia_safe::Color4f::new(
|
||||
hue.0 * 0.45,
|
||||
hue.1 * 0.45,
|
||||
hue.2 * 0.45,
|
||||
1.0,
|
||||
)),
|
||||
);
|
||||
surface
|
||||
.image_snapshot()
|
||||
.encode(None, skia_safe::EncodedImageFormat::PNG, 100)
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
/// Games across four platforms — what the collections screen is for, and what the flat
|
||||
/// `platform: None` library above cannot produce.
|
||||
fn platform_games() -> Vec<crate::library::LibraryGame> {
|
||||
[
|
||||
("Gran Turismo 6", "PlayStation 3"),
|
||||
("The Last of Us", "PlayStation 3"),
|
||||
("Demon's Souls", "PlayStation 3"),
|
||||
("Halo 3", "Xbox 360"),
|
||||
("Fable II", "Xbox 360"),
|
||||
("Super Metroid", "SNES"),
|
||||
("Chrono Trigger", "SNES"),
|
||||
("Sonic 2", "Mega Drive"),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (title, platform))| crate::library::LibraryGame {
|
||||
id: format!("rom:{i}"),
|
||||
title: (*title).to_string(),
|
||||
store: "rom-manager".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
platform: Some((*platform).to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collections_shell_inner(
|
||||
with_art: bool,
|
||||
) -> (Shell, ConsoleShared, crate::library::LibraryShared) {
|
||||
fake_home();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let games = platform_games();
|
||||
if with_art {
|
||||
for (i, g) in games.iter().enumerate() {
|
||||
library.push_art(g.id.clone(), poster_png(i));
|
||||
}
|
||||
}
|
||||
library.set_games(games);
|
||||
let console = ConsoleShared::default();
|
||||
console.set_hosts(hosts());
|
||||
let sh = Shell::new(
|
||||
console.clone(),
|
||||
library.clone(),
|
||||
ConsoleBus::default(),
|
||||
ConsoleOptions {
|
||||
device_name: "deck".into(),
|
||||
deck: false,
|
||||
},
|
||||
vec![
|
||||
Screen::Home(HomeScreen::new()),
|
||||
Screen::Library(LibraryScreen::new(&hosts()[0])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
(sh, console, library)
|
||||
}
|
||||
|
||||
fn collections_shell() -> (Shell, ConsoleShared, crate::library::LibraryShared) {
|
||||
collections_shell_inner(true)
|
||||
}
|
||||
|
||||
fn collections_shell_no_art() -> (Shell, ConsoleShared, crate::library::LibraryShared) {
|
||||
collections_shell_inner(false)
|
||||
}
|
||||
|
||||
/// The bounding box of everything lit on a raster surface, in pixels: `(left, right, bottom)`.
|
||||
/// White ink on a cleared black field, so any channel answers.
|
||||
fn ink_bounds(surface: &mut skia_safe::Surface, w: i32, h: i32) -> (i32, i32, i32) {
|
||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
|
||||
assert!(
|
||||
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0)),
|
||||
"raster surface read-back"
|
||||
);
|
||||
let (mut left, mut right, mut bottom) = (i32::MAX, i32::MIN, i32::MIN);
|
||||
for (i, px) in pixels.chunks_exact(4).enumerate() {
|
||||
if px[0] > 60 {
|
||||
let (x, y) = (i as i32 % w, i as i32 / w);
|
||||
left = left.min(x);
|
||||
right = right.max(x);
|
||||
bottom = bottom.max(y);
|
||||
}
|
||||
}
|
||||
assert!(left <= right, "nothing was drawn");
|
||||
(left, right, bottom)
|
||||
}
|
||||
|
||||
/// A screen heading starts on its column and stays on ONE line.
|
||||
///
|
||||
/// Both halves are the defect this replaced. The heading used to be centred, which read as a
|
||||
/// floating label rather than as a section heading — every other punktfunk client anchors it
|
||||
/// to the leading edge — and, being a wrapping paragraph, a long host name grew a SECOND line
|
||||
/// downward into the screen's content. Asserted against a control render of the same string
|
||||
/// with room to spare rather than against a pixel row, so the line box is Geist's to define:
|
||||
/// the clamped heading must occupy the same one line the unclamped one does.
|
||||
#[test]
|
||||
fn a_heading_starts_on_its_column_and_never_takes_a_second_line() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let (w, h) = (1200, 200);
|
||||
let (x, y, size) = (crate::theme::EDGE_INSET, 18.0, 30.0);
|
||||
let title = "Living Room PC · Performance · PlayStation 3";
|
||||
let render = |max_w: f64| {
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||
surface
|
||||
.canvas()
|
||||
.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||||
fonts.heading(
|
||||
surface.canvas(),
|
||||
title,
|
||||
crate::theme::W::Bold,
|
||||
size,
|
||||
skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0),
|
||||
x,
|
||||
y,
|
||||
max_w,
|
||||
);
|
||||
ink_bounds(&mut surface, w, h)
|
||||
};
|
||||
|
||||
// Room to spare: one line, and the ink begins at the column (a cap's left sidebearing
|
||||
// puts it a pixel or two right of the paragraph's origin, never left of it).
|
||||
let (loose_left, loose_right, loose_bottom) = render(1100.0);
|
||||
assert!(
|
||||
(loose_left as f64) >= x - 1.0 && (loose_left as f64) < x + 0.1 * 1100.0,
|
||||
"heading ink starts at {loose_left}, which is not the {x} column"
|
||||
);
|
||||
assert!(
|
||||
loose_right < w,
|
||||
"the control render was clipped by the surface"
|
||||
);
|
||||
|
||||
// Squeezed: it ellipsizes inside the budget instead of wrapping, so its ink ends where
|
||||
// the budget does and its bottom stays on the control's single line.
|
||||
let budget = 300.0;
|
||||
let (tight_left, tight_right, tight_bottom) = render(budget);
|
||||
assert_eq!(
|
||||
tight_left, loose_left,
|
||||
"clamping the width must not move the heading's left edge"
|
||||
);
|
||||
assert!(
|
||||
(tight_right as f64) <= x + budget + 1.0,
|
||||
"heading ran to {tight_right}, past its {} budget",
|
||||
x + budget
|
||||
);
|
||||
assert!(
|
||||
tight_bottom <= loose_bottom + 1,
|
||||
"heading wrapped to a second line: it reaches {tight_bottom} where one line ends at \
|
||||
{loose_bottom}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The console's geometry is ANTI-ALIASED — the defect this pins shipped in the overhaul and
|
||||
/// was only caught by looking at a Deck.
|
||||
///
|
||||
/// Skia defaults `SkPaint::fAntiAlias` to FALSE, so `Paint::new(colour, None)` — the terse and
|
||||
/// obvious way to write a draw call — produces hard-stepped edges. The console drew nearly
|
||||
/// everything that way: glass panels, the badge round-rects, the online pip, the D-pad and
|
||||
/// PlayStation glyph paths. Only paints that happened to be mutated for some other reason (a
|
||||
/// stroke style, a width) had picked up a `set_anti_alias(true)` along the way, which is why
|
||||
/// the console shipped smooth 1 px rings sitting on top of jagged fills.
|
||||
///
|
||||
/// Asserted on a SHAPE rather than on a screen: a full render is a poor witness here — one
|
||||
/// jagged corner is a few dozen pixels in 1.02 M, and no threshold that catches it survives an
|
||||
/// unrelated palette tweak. A lone circle on a blank field is unambiguous. With AA its boundary
|
||||
/// is a ring of PARTIAL coverage; without it every pixel is one of exactly two values.
|
||||
#[test]
|
||||
fn geometry_is_anti_aliased() {
|
||||
let (w, h) = (64, 64);
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||
surface
|
||||
.canvas()
|
||||
.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||||
// Deliberately off the pixel grid: a circle centred on a half-pixel has an edge that
|
||||
// cannot be represented exactly, which is when AA is the whole difference.
|
||||
surface.canvas().draw_circle(
|
||||
skia_safe::Point::new(31.5, 31.5),
|
||||
20.3,
|
||||
&crate::theme::fill(skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0)),
|
||||
);
|
||||
|
||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
|
||||
assert!(
|
||||
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0)),
|
||||
"raster surface read-back"
|
||||
);
|
||||
// Red channel alone — the fill is white on black, so all three agree.
|
||||
let partial = pixels
|
||||
.chunks_exact(4)
|
||||
.filter(|px| (8..248).contains(&px[0]))
|
||||
.count();
|
||||
assert!(
|
||||
partial > 40,
|
||||
"an anti-aliased circle of r≈20 has a boundary ring of partially covered pixels; found \
|
||||
{partial}, which is what `Paint::new`'s aliased default looks like"
|
||||
);
|
||||
}
|
||||
|
||||
/// A shader-painted element actually PAINTS — the second trap in the same corner, and the one
|
||||
/// that cost a whole screenshot round.
|
||||
///
|
||||
/// Skia modulates a shader's output by the paint's ALPHA. `Paint::default` is opaque black, so
|
||||
/// the console's gradients and the aurora's runtime effect never noticed the rule existed; the
|
||||
/// moment those paints were rebuilt from a "the shader supplies the colour anyway" transparent
|
||||
/// placeholder, every one of them drew NOTHING. Not dimmer, not wrong-coloured — absent: the
|
||||
/// backdrop, the badge, the vignette and the panel's gradient stroke all vanished at once, and
|
||||
/// every test still passed, because a test that only renders a frame cannot tell a missing layer
|
||||
/// from a dark one. `theme::shaded` is opaque by construction; this holds it to that.
|
||||
#[test]
|
||||
fn a_shaded_paint_is_opaque_enough_to_draw() {
|
||||
let (w, h) = (32, 32);
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||
surface
|
||||
.canvas()
|
||||
.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||||
let mut p = crate::theme::shaded();
|
||||
let stops = [
|
||||
skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0),
|
||||
skia_safe::Color4f::new(1.0, 1.0, 1.0, 1.0),
|
||||
];
|
||||
p.set_shader(skia_safe::gradient::shaders::linear_gradient(
|
||||
(
|
||||
skia_safe::Point::new(0.0, 0.0),
|
||||
skia_safe::Point::new(0.0, h as f32),
|
||||
),
|
||||
&skia_safe::gradient::Gradient::new(
|
||||
skia_safe::gradient::Colors::new_evenly_spaced(
|
||||
&stops,
|
||||
skia_safe::TileMode::Clamp,
|
||||
None,
|
||||
),
|
||||
skia_safe::gradient::Interpolation::default(),
|
||||
),
|
||||
None,
|
||||
));
|
||||
surface
|
||||
.canvas()
|
||||
.draw_rect(skia_safe::Rect::from_wh(w as f32, h as f32), &p);
|
||||
|
||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
|
||||
assert!(
|
||||
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0)),
|
||||
"raster surface read-back"
|
||||
);
|
||||
let lit = pixels.chunks_exact(4).filter(|px| px[0] > 200).count();
|
||||
assert_eq!(
|
||||
lit,
|
||||
(w * h) as usize,
|
||||
"an opaque white gradient over the whole surface should light every pixel; a paint \
|
||||
whose own alpha is 0 scales the shader away and leaves the field black"
|
||||
);
|
||||
}
|
||||
|
||||
/// …and every paint in the crate is built by `theme::fill`/`stroke`/`layer`, so the assertion
|
||||
/// above keeps holding for code written after it.
|
||||
///
|
||||
/// A pixel test can only witness the shapes it happens to draw; this witnesses the CLASS. The
|
||||
/// trap is that the aliased spelling is the NATURAL one — `&Paint::new(c, None)` passed inline
|
||||
/// as an argument, no binding, no obvious place to hang a flag — so it reappears whenever a new
|
||||
/// draw call is written, in whichever file is being worked on that day. Reading the crate's own
|
||||
/// source is the only check that scales to that.
|
||||
#[test]
|
||||
fn paints_are_built_by_the_theme_constructors() {
|
||||
// Split so the needles do not appear literally in this file — the scan reads its own
|
||||
// source too, and a self-match is the first thing this test did.
|
||||
let needles = [concat!("Paint", "::new("), concat!("Paint", "::default()")];
|
||||
let mut offenders = Vec::new();
|
||||
let mut stack = vec![std::path::PathBuf::from(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/src"
|
||||
))];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).expect("the crate's own src is readable") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|e| e != "rs") {
|
||||
continue;
|
||||
}
|
||||
// theme.rs holds the sanctioned constructors, and is the one place the raw ones
|
||||
// are allowed.
|
||||
if path.file_name().is_some_and(|f| f == "theme.rs") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("source is UTF-8");
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let code = line.trim_start();
|
||||
if code.starts_with("//") || code.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
if needles.iter().any(|needle| code.contains(needle)) {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
offenders.push(format!("{name}:{}: {code}", n + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these build a Skia paint directly, which means anti-aliasing is OFF on whatever they \
|
||||
draw — use `theme::fill`, `theme::stroke`, or `theme::layer` for a `save_layer` \
|
||||
paint:\n {}",
|
||||
offenders.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::model::{ConsoleBus, ConsoleShared, HostRow};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::Screen;
|
||||
use crate::shell::{ConsoleOptions, Shell};
|
||||
use crate::theme::{match_first_family, Fonts};
|
||||
use crate::theme::{fill, match_first_family, Fonts};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use ash::vk as avk;
|
||||
use ash::vk::Handle as _;
|
||||
@@ -22,12 +22,26 @@ use pf_presenter::overlay::{
|
||||
};
|
||||
use skia_safe::gpu::vk as skvk;
|
||||
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
|
||||
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
|
||||
use skia_safe::{Canvas, Color4f, Font, FontMgr, Point, RRect, Rect, Surface};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits
|
||||
/// Deck-class shared memory.
|
||||
const RESOURCE_CACHE_BYTES: usize = 64 << 20;
|
||||
/// Skia's GPU resource budget — poster art plus a few screen layers.
|
||||
///
|
||||
/// A CEILING, not an allocation: Skia grows into it only under demand, and the console's
|
||||
/// demand is now small — with the library's posters cached at the size they are drawn
|
||||
/// (`screens::library::art_cache_size`) a full grid at Deck scale asks for ~30 MB. What
|
||||
/// matters is the HEADROOM. At 64 MB the budget sat under a full grid's working set: a
|
||||
/// screenful of full-resolution covers is ~100 MB, so `GrResourceCache` evicted a third of
|
||||
/// them on every submit and the next frame re-decoded them from JPEG on the render thread.
|
||||
/// That was the grid's slideshow, and a cliff rather than a slope — which is exactly how it
|
||||
/// was reported, smooth until the screen filled.
|
||||
///
|
||||
/// 160 MB clears the working set several times over at every scale a panel up to 1440p
|
||||
/// produces, with room for the two render targets and the glyph atlases. The one arrangement
|
||||
/// that can still crowd it is a 4K panel (`k` 2.7, 33 MB a render target) fed 1000×1500
|
||||
/// SteamGridDB portraits, where full resolution is genuinely what gets drawn — and that is a
|
||||
/// desktop GPU by the time it happens.
|
||||
pub(crate) const RESOURCE_CACHE_BYTES: usize = 160 << 20;
|
||||
|
||||
/// How long the start-of-stream banner lingers (fading through the tail).
|
||||
const BANNER_S: f64 = 6.0;
|
||||
@@ -751,9 +765,9 @@ fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, sca
|
||||
let radius = base::OSD_RADIUS * scale;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(panel, radius, radius),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62)),
|
||||
);
|
||||
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
|
||||
let text_paint = fill(Color4f::new(1.0, 1.0, 1.0, 0.92));
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
canvas.draw_str(
|
||||
line,
|
||||
@@ -789,12 +803,12 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3
|
||||
let (x, y) = (width as f32 - w - margin, margin);
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62)),
|
||||
);
|
||||
canvas.draw_circle(
|
||||
Point::new(x + pad_x + dot_r, y + h / 2.0),
|
||||
dot_r,
|
||||
&Paint::new(crate::theme::ERROR, None),
|
||||
&fill(crate::theme::ERROR),
|
||||
);
|
||||
canvas.draw_str(
|
||||
LABEL,
|
||||
@@ -803,7 +817,7 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3
|
||||
y + pad_y - metrics.ascent,
|
||||
),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
|
||||
&fill(Color4f::new(1.0, 1.0, 1.0, 0.92)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -839,13 +853,13 @@ fn draw_access_chip(
|
||||
let x = width as f32 - w - margin;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62)),
|
||||
);
|
||||
canvas.draw_str(
|
||||
text,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
|
||||
&fill(Color4f::new(1.0, 1.0, 1.0, 0.92)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -868,7 +882,7 @@ fn draw_resize_scrim(
|
||||
let (wf, hf) = (width as f32, height as f32);
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(wf, hf),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
&fill(Color4f::new(0.0, 0.0, 0.0, 0.55)),
|
||||
);
|
||||
// Spinner slightly above center; the label sits below it.
|
||||
let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
|
||||
@@ -881,7 +895,7 @@ fn draw_resize_scrim(
|
||||
label,
|
||||
Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
|
||||
&fill(Color4f::new(1.0, 1.0, 1.0, 0.9)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -914,12 +928,12 @@ fn draw_hint_pill(
|
||||
let y = height as f32 - h - base::PILL_BOTTOM * scale;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
|
||||
&fill(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha)),
|
||||
);
|
||||
canvas.draw_str(
|
||||
text,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent),
|
||||
font,
|
||||
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None),
|
||||
&fill(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,83 @@ use skia_safe::{
|
||||
RRect, Rect, TileMode, Typeface,
|
||||
};
|
||||
|
||||
// --- Paint ----------------------------------------------------------------------------------
|
||||
|
||||
/// A filled paint, ANTI-ALIASED. Build every fill in this crate here.
|
||||
///
|
||||
/// Skia's `SkPaint` defaults `fAntiAlias` to **false**, and `Paint::new(colour, None)` is that
|
||||
/// default constructor with a colour on it — so the natural, terse way to write a Skia draw call
|
||||
/// (`canvas.draw_rrect(rr, &Paint::new(c, None))`) silently produces HARD-STEPPED geometry. That
|
||||
/// is the wrong default for this crate twice over: the console draws almost nothing axis-aligned
|
||||
/// (round-rects, circles, arcs, D-pad and stick paths), and it is read from a couch on a 1280×800
|
||||
/// Deck panel, where a stair-stepped 16 px glyph circle reads as a visible octagon.
|
||||
///
|
||||
/// The bug this exists to prevent is specific and it already happened: paints that got MUTATED
|
||||
/// for some other reason (a stroke style, a width) collected a `set_anti_alias(true)` along the
|
||||
/// way, while every inline `&Paint::new(…)` argument did not. The console therefore shipped
|
||||
/// smooth 1 px rings drawn on top of jagged fills — which is worse-looking than no ring at all,
|
||||
/// because the smooth edge gives the eye a reference for how wrong the fill is.
|
||||
///
|
||||
/// `theme::fill`/[`stroke`]/[`layer`] are the only sanctioned constructors, and
|
||||
/// `shell::tests::paints_are_built_by_the_theme_constructors` fails the build if a bare
|
||||
/// `Paint::new`/`Paint::default` reappears anywhere outside this file.
|
||||
pub(crate) fn fill(color: Color4f) -> Paint {
|
||||
let mut p = Paint::new(color, None);
|
||||
p.set_anti_alias(true);
|
||||
p
|
||||
}
|
||||
|
||||
/// A stroking paint of `width` DEVICE pixels, anti-aliased. Callers scaling by `k` pass
|
||||
/// `width * k` — nothing here knows about design units.
|
||||
pub(crate) fn stroke(color: Color4f, width: f32) -> Paint {
|
||||
let mut p = fill(color);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width(width);
|
||||
p
|
||||
}
|
||||
|
||||
/// A paint whose colour comes from a SHADER — a gradient, or the aurora's runtime effect.
|
||||
/// Anti-aliased, and OPAQUE by construction, which is the whole point of it existing.
|
||||
///
|
||||
/// The colour channel is unused once a shader is attached, so the obvious thing is to build
|
||||
/// one of these from a transparent placeholder and let the shader supply everything. That is
|
||||
/// a trap: Skia modulates a shader's output by the PAINT'S ALPHA, so an alpha-0 placeholder
|
||||
/// draws nothing whatever the shader says. It is a silent, total failure — the element simply
|
||||
/// is not there — and it is invisible to a test that only asserts a frame renders without
|
||||
/// panicking. `Paint::default` happened to be opaque black and so never showed the problem;
|
||||
/// anything replacing it has to be deliberately opaque, so that is what this is.
|
||||
pub(crate) fn shaded() -> Paint {
|
||||
fill(Color4f::new(0.0, 0.0, 0.0, 1.0))
|
||||
}
|
||||
|
||||
/// [`shaded`]'s stroking twin — a gradient hairline, opaque so the gradient survives.
|
||||
pub(crate) fn shaded_stroke(width: f32) -> Paint {
|
||||
let mut p = shaded();
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width(width);
|
||||
p
|
||||
}
|
||||
|
||||
/// The paint for a `save_layer` — it carries alpha and colour filters, and never any geometry
|
||||
/// of its own, so anti-aliasing has nothing to act on. Its own constructor so the AA guard (and
|
||||
/// a reader) can tell a compositing paint from a drawing one without reading the call site.
|
||||
pub(crate) fn layer() -> Paint {
|
||||
Paint::default()
|
||||
}
|
||||
|
||||
/// How the console samples bitmap art (poster/cover images, launcher icons).
|
||||
///
|
||||
/// `Canvas::draw_image_rect`'s default is `SamplingOptions::default()` — `FilterMode::Nearest`
|
||||
/// with `MipmapMode::None`, i.e. NO filtering at all. Every cover in the library is minified
|
||||
/// hard (a 600×900 poster into a ~180×270 Deck cell), and nearest-neighbour minification drops
|
||||
/// whole rows and columns of source pixels: box-art lettering breaks up, edges crawl as the
|
||||
/// shelf scrolls, and the result reads as "low resolution" no matter what the panel is. Linear
|
||||
/// with a linear mipmap chain is the fix — the mip level does the bulk of the reduction, so the
|
||||
/// filter is never asked to shrink by more than 2×, which is the one thing bilinear does well.
|
||||
pub(crate) fn art_sampling() -> skia_safe::SamplingOptions {
|
||||
skia_safe::SamplingOptions::new(skia_safe::FilterMode::Linear, skia_safe::MipmapMode::Linear)
|
||||
}
|
||||
|
||||
// --- Ink ----------------------------------------------------------------------------------
|
||||
|
||||
/// The error/status red (the GTK client's #ff938a). Fixed: a warning must not change meaning
|
||||
@@ -132,6 +209,26 @@ pub(crate) fn shade(alpha: f32) -> Color4f {
|
||||
Color4f::new(s.r, s.g, s.b, alpha * s.a)
|
||||
}
|
||||
|
||||
/// An OPAQUE card face, `tint` of the way from the ground side of the field toward the
|
||||
/// palette's accent — the backdrop for a cover we have no art for.
|
||||
///
|
||||
/// Opaque is the constraint that rules the alternatives out: coverflow side cards OVERLAP, so
|
||||
/// a glass face would show its neighbour through it, and `accent(0.20)` over nothing is
|
||||
/// exactly that. Mixing the same tint into a base the field's own lean chooses (black under a
|
||||
/// dark palette, white under a pale one, which is what [`Ink::scrim`] already knows) gets the
|
||||
/// accent tint with no alpha spent.
|
||||
///
|
||||
/// The pairing with [`fg`] is the point of it. A fixed near-black face carrying `fg()` ink was
|
||||
/// legible on the seven dark palettes and ABSENT on the six pale ones, where `fg()` is itself a
|
||||
/// near-black tinted toward the ground — the two composited to 1.03:1. Face and ink now move in
|
||||
/// opposite directions with the palette, so they separate at both poles by construction.
|
||||
pub(crate) fn card_face(tint: f32) -> Color4f {
|
||||
let a = ink().accent;
|
||||
let base = if ink().scrim.r > 0.5 { 1.0 } else { 0.0 };
|
||||
let mix = |c: f32| c * tint + base * (1.0 - tint);
|
||||
Color4f::new(mix(a.r), mix(a.g), mix(a.b), 1.0)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -169,14 +266,14 @@ pub(crate) fn panel(
|
||||
k: f32,
|
||||
) {
|
||||
let rr = RRect::new_rect_xy(rect, corner * k, corner * k);
|
||||
canvas.draw_rrect(rr, &Paint::new(ink().glass, None));
|
||||
canvas.draw_rrect(rr, &fill(ink().glass));
|
||||
if let Some(tint) = tint {
|
||||
canvas.draw_rrect(rr, &Paint::new(tint, None));
|
||||
canvas.draw_rrect(rr, &fill(tint));
|
||||
}
|
||||
let mut sp = Paint::default();
|
||||
sp.set_style(skia_safe::PaintStyle::Stroke);
|
||||
sp.set_stroke_width(1.0);
|
||||
sp.set_anti_alias(true);
|
||||
// Opaque to start with: the Plain/Brand arms overwrite the colour outright, and the
|
||||
// gradient arms attach a shader whose output this paint's alpha would otherwise scale
|
||||
// away to nothing.
|
||||
let mut sp = shaded_stroke(1.0);
|
||||
match stroke {
|
||||
PanelStroke::Plain(alpha) => {
|
||||
sp.set_color4f(fg(alpha), None);
|
||||
@@ -205,7 +302,6 @@ pub(crate) fn panel(
|
||||
canvas.draw_rrect(rr, &sp);
|
||||
}
|
||||
|
||||
/// The soft drop shadow under a focused tile — a blurred black round-rect behind it.
|
||||
/// The colour half of the focus recede: neighbours lose SATURATION and BRIGHTNESS with
|
||||
/// distance `d` (0 = focused, 1 = fully receded), as one 4×5 row-major matrix.
|
||||
///
|
||||
@@ -215,9 +311,8 @@ pub(crate) fn panel(
|
||||
/// its old strength, doing the job it is actually good at — separating overlapping cards.
|
||||
///
|
||||
/// Row-major `[r…, g…, b…, a…]`, each row `[R G B A offset]`. The RGB rows are a standard
|
||||
/// luminance-weighted saturation matrix (Rec. 709 weights) scaled by `sat`, with the
|
||||
/// brightness shift in the offset column — SwiftUI's `.brightness()` is additive, and
|
||||
/// matching it keeps the two codebases' recede comparable by eye.
|
||||
/// luminance-weighted saturation matrix (Rec. 709 weights) scaled by `sat`, the whole of it
|
||||
/// then LERPED toward the ground: `out = (1 − b)·sat_mix(c) + ground·b`.
|
||||
pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
|
||||
let d = d.clamp(0.0, 1.0);
|
||||
let sat = (1.0 - RECEDE_SATURATION * d) as f32;
|
||||
@@ -228,27 +323,37 @@ pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
|
||||
// which way the field leans (it tends to black on a dark palette, white on a pale
|
||||
// one), so the recede borrows its direction.
|
||||
let toward_light = ink().scrim.r > 0.5;
|
||||
let bright = (RECEDE_BRIGHTNESS * d) as f32 * if toward_light { 1.0 } else { -1.0 };
|
||||
// A FRACTION of the way to the ground, not a level offset. SwiftUI's `.brightness()` is
|
||||
// additive and this matched it, which meant −0.24 was −61/255 on every channel and
|
||||
// Skia's colour matrix clamps: the coverflow's own #1E1E25 placeholder came out at
|
||||
// literal #000000, the whole side stack a single black slab with no depth in it and no
|
||||
// cover-art detail left to see. A lerp cannot clip at either pole, and it is also the
|
||||
// arithmetic "receding into the field" actually means — a fixed subtraction is a
|
||||
// different amount of recede for every card and total annihilation for a dark one.
|
||||
let b = (RECEDE_BRIGHTNESS * d) as f32;
|
||||
let ground = if toward_light { 1.0f32 } else { 0.0 };
|
||||
let keep = 1.0 - b;
|
||||
let offset = ground * b;
|
||||
const LR: f32 = 0.2126;
|
||||
const LG: f32 = 0.7152;
|
||||
const LB: f32 = 0.0722;
|
||||
let (ir, ig, ib) = (LR * (1.0 - sat), LG * (1.0 - sat), LB * (1.0 - sat));
|
||||
[
|
||||
ir + sat,
|
||||
ig,
|
||||
ib,
|
||||
keep * (ir + sat),
|
||||
keep * ig,
|
||||
keep * ib,
|
||||
0.0,
|
||||
bright,
|
||||
ir,
|
||||
ig + sat,
|
||||
ib,
|
||||
offset,
|
||||
keep * ir,
|
||||
keep * (ig + sat),
|
||||
keep * ib,
|
||||
0.0,
|
||||
bright,
|
||||
ir,
|
||||
ig,
|
||||
ib + sat,
|
||||
offset,
|
||||
keep * ir,
|
||||
keep * ig,
|
||||
keep * (ib + sat),
|
||||
0.0,
|
||||
bright,
|
||||
offset,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
@@ -257,10 +362,16 @@ pub(crate) fn recede_matrix(d: f64) -> [f32; 20] {
|
||||
]
|
||||
}
|
||||
|
||||
/// How much colour a fully receded neighbour loses…
|
||||
const RECEDE_SATURATION: f64 = 0.42;
|
||||
/// …and how much light. Both ported from the Apple gamepad UI's focus recede.
|
||||
const RECEDE_BRIGHTNESS: f64 = 0.24;
|
||||
/// How much colour a fully receded neighbour loses. Gentle enough that a side card still
|
||||
/// reads as the artwork it is — drain more and the shelf looks like a filter was applied to
|
||||
/// it rather than like the cards are standing further away. Cannot go below 0.125 while the
|
||||
/// brightness term is 0.20: `recede_matrix_drains_colour_and_light_but_never_alpha` wants
|
||||
/// the channel spread cut by 30 %, and spread scales exactly as `(1 − b)·sat`.
|
||||
const RECEDE_SATURATION: f64 = 0.34;
|
||||
/// …and how far it travels toward the ground, as a FRACTION of the distance — never as a
|
||||
/// level offset, whatever the Apple gamepad UI's `.brightness()` does. See
|
||||
/// [`recede_matrix`]: an additive term clips, and a card dark enough clips to nothing.
|
||||
const RECEDE_BRIGHTNESS: f64 = 0.20;
|
||||
|
||||
/// The lit top edge that makes glass read as a material rather than as a tinted rectangle:
|
||||
/// a 1 px inner stroke fading from `fg(0.10)` to nothing over the top 40 % of the panel,
|
||||
@@ -272,10 +383,7 @@ const RECEDE_BRIGHTNESS: f64 = 0.24;
|
||||
/// instead of hiding it behind a default.
|
||||
pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32) {
|
||||
let inset = rect.with_inset((0.5 * k, 0.5 * k));
|
||||
let mut p = Paint::default();
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width(k.max(1.0));
|
||||
p.set_anti_alias(true);
|
||||
let mut p = shaded_stroke(k.max(1.0));
|
||||
let colors = [fg(0.10), fg(0.0)];
|
||||
p.set_shader(gradient::shaders::linear_gradient(
|
||||
(
|
||||
@@ -299,20 +407,54 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
|
||||
if f <= 0.01 {
|
||||
return;
|
||||
}
|
||||
let mut p = Paint::new(accent(0.28 * f), None);
|
||||
// Every pale palette's accent is DARK — mint 0.34 luma, sunset 0.26, opal 0.33 — so a
|
||||
// blurred accent on a pale field is a smudge, and the focused tile came out the dirtiest
|
||||
// thing in the row while its unfocused neighbours stayed clean and light: the focus mark
|
||||
// inverted. Mixing halfway to the scrim (white there) keeps the palette's own hue while
|
||||
// making the mark read as light. It needs a little more body to register once lightened.
|
||||
let (a, s) = (ink().accent, ink().scrim);
|
||||
let (c, alpha) = if s.r > 0.5 {
|
||||
let mix = |x: f32, y: f32| x + (y - x) * 0.5;
|
||||
(
|
||||
Color4f::new(mix(a.r, s.r), mix(a.g, s.g), mix(a.b, s.b), 1.0),
|
||||
0.24 * f,
|
||||
)
|
||||
} else {
|
||||
(a, 0.20 * f)
|
||||
};
|
||||
let mut p = fill(Color4f::new(c.r, c.g, c.b, alpha));
|
||||
// Outer, not Normal: Normal keeps the blurred shape's INTERIOR, so the halo also filled
|
||||
// the card's own footprint at full accent. On the home and collections tiles the panel
|
||||
// glass over it is translucent (α 0.62 dark, 0.66 pale), so a third of that came through
|
||||
// the face and the focused card read as a lit blob rather than as a card with light
|
||||
// spilling around it.
|
||||
p.set_mask_filter(MaskFilter::blur(
|
||||
skia_safe::BlurStyle::Normal,
|
||||
18.0 * k,
|
||||
skia_safe::BlurStyle::Outer,
|
||||
10.0 * k,
|
||||
None,
|
||||
));
|
||||
// Grown slightly rather than offset: a halo is light spilling out of the card on every
|
||||
// side, where the shadow below it is the card's weight falling in one direction.
|
||||
let spread = rect.with_outset((6.0 * k, 6.0 * k));
|
||||
// side, where the shadow below it is the card's weight falling in one direction. The
|
||||
// reach is outset + 3σ and it has to stay INSIDE the gap to the next card: at 6 + 3·18
|
||||
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
|
||||
// farthest-first the focused card's corona landed on top of its neighbours — which is
|
||||
// what made every card look like it was glowing.
|
||||
let spread = rect.with_outset((4.0 * k, 4.0 * k));
|
||||
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
|
||||
}
|
||||
|
||||
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
|
||||
let mut p = Paint::new(Color4f::new(0.0, 0.0, 0.0, alpha), None);
|
||||
// Black under a tile is WEIGHT on a dark field and DIRT on a pale one, where it is the
|
||||
// heaviest mark on the screen: on `holo` and `sunset` the focused tile sat in a muddy
|
||||
// grey-brown ring while every unfocused tile stayed clean. Scaled back at the pale pole
|
||||
// the same way the scrim already scales itself, so the caller's alpha keeps meaning
|
||||
// "dark-field strength" and no call site has to know which palette is up.
|
||||
let alpha = if ink().scrim.r > 0.5 {
|
||||
alpha * 0.40
|
||||
} else {
|
||||
alpha
|
||||
};
|
||||
let mut p = fill(Color4f::new(0.0, 0.0, 0.0, alpha));
|
||||
p.set_mask_filter(MaskFilter::blur(
|
||||
skia_safe::BlurStyle::Normal,
|
||||
10.0 * k,
|
||||
@@ -333,11 +475,8 @@ 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(fg(0.85), None);
|
||||
paint.set_style(skia_safe::PaintStyle::Stroke);
|
||||
paint.set_stroke_width((r / 5.0) as f32);
|
||||
let mut paint = stroke(fg(0.85), (r / 5.0) as f32);
|
||||
paint.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
paint.set_anti_alias(true);
|
||||
canvas.draw_arc(
|
||||
Rect::from_xywh(
|
||||
(cx - r) as f32,
|
||||
@@ -352,6 +491,21 @@ pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Layout -------------------------------------------------------------------------------
|
||||
|
||||
/// How far in from a screen's edge its CHROME sits, design units — the heading, the section
|
||||
/// strip under it and the controller chip on the right all share this one column.
|
||||
///
|
||||
/// The number is the other clients' verbatim: Apple pads every gamepad heading and its tab
|
||||
/// strip `.horizontal, 24`, Android names it `ConsoleEdgeInset = 24.dp`. It is deliberately
|
||||
/// NOT the legend's 18 — that is a PILL's edge, whose first glyph lands at 31, so matching it
|
||||
/// would misalign the very thing it was copied from.
|
||||
///
|
||||
/// It is a screen inset, not a content margin: the rows, the carousel and the coverflow are
|
||||
/// all CENTRED columns, so aligning a heading to one would mean tracking `(width − column)/2`,
|
||||
/// which is an artefact of the window size rather than a margin anyone chose.
|
||||
pub(crate) const EDGE_INSET: f64 = 24.0;
|
||||
|
||||
// --- Typography ---------------------------------------------------------------------------
|
||||
|
||||
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
|
||||
@@ -445,7 +599,7 @@ impl Fonts {
|
||||
text,
|
||||
Point::new(x as f32, baseline as f32),
|
||||
&font,
|
||||
&Paint::new(color, None),
|
||||
&fill(color),
|
||||
);
|
||||
font.measure_str(text, None).0
|
||||
}
|
||||
@@ -465,7 +619,7 @@ impl Fonts {
|
||||
color: Color4f,
|
||||
) {
|
||||
let font = self.font(w, size);
|
||||
let paint = Paint::new(color, None);
|
||||
let paint = fill(color);
|
||||
let mut pen = x as f32;
|
||||
let mut buf = [0u8; 4];
|
||||
for ch in text.chars() {
|
||||
@@ -475,6 +629,10 @@ impl Fonts {
|
||||
}
|
||||
}
|
||||
|
||||
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
|
||||
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
|
||||
/// screen's content, which is why both other clients pin theirs to one line.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paragraph(
|
||||
&self,
|
||||
text: &str,
|
||||
@@ -483,9 +641,14 @@ impl Fonts {
|
||||
color: Color4f,
|
||||
align: TextAlign,
|
||||
max_w: f64,
|
||||
clamp: Option<usize>,
|
||||
) -> skia_safe::textlayout::Paragraph {
|
||||
let mut style = ParagraphStyle::new();
|
||||
style.set_text_align(align);
|
||||
if let Some(lines) = clamp {
|
||||
style.set_max_lines(lines);
|
||||
style.set_ellipsis("\u{2026}");
|
||||
}
|
||||
let mut ts = TextStyle::new();
|
||||
ts.set_font_families(&["Geist"]);
|
||||
ts.set_font_size(size as f32);
|
||||
@@ -525,10 +688,53 @@ impl Fonts {
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w);
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
|
||||
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
|
||||
}
|
||||
|
||||
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
|
||||
/// top. Same paragraph path, so it shapes and falls back for CJK exactly as `centered`
|
||||
/// does — which is why the screen chrome cannot use `draw`/`draw_clipped` instead.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn leading(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
w: W,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
x: f64,
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
}
|
||||
|
||||
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
|
||||
/// line at `max_w`.
|
||||
///
|
||||
/// Every punktfunk client anchors its console heading to the leading edge — Apple's
|
||||
/// carries the note that a centred one "read as a floating label" rather than as a
|
||||
/// section heading, Android's `ConsoleHeader` pins it to `ConsoleEdgeInset`. The single
|
||||
/// line is not cosmetic either: left-aligned, a long host name would otherwise wrap under
|
||||
/// the controller chip and push a second line into the content.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn heading(
|
||||
&self,
|
||||
canvas: &Canvas,
|
||||
text: &str,
|
||||
w: W,
|
||||
size: f64,
|
||||
color: Color4f,
|
||||
x: f64,
|
||||
y: f64,
|
||||
max_w: f64,
|
||||
) {
|
||||
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
|
||||
p.paint(canvas, Point::new(x as f32, y as f32));
|
||||
}
|
||||
|
||||
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
|
||||
/// host/game titles that may exceed their tile.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -673,4 +879,29 @@ mod tests {
|
||||
|
||||
set_ink(DARK_INK);
|
||||
}
|
||||
|
||||
/// The recede must never CLAMP. The other three assertions here are all relative and
|
||||
/// none of them feeds the matrix a dark input, which is exactly how a brightness term
|
||||
/// that subtracted 61/255 in the offset column shipped: the coverflow's own placeholder
|
||||
/// face came out at literal #000000 on every card a slot or more from the focus, so the
|
||||
/// side stack was one black slab with no depth and no cover-art detail in it. `apply`
|
||||
/// omits Skia's clamp deliberately, so a channel below zero here IS the shipped bug.
|
||||
#[test]
|
||||
fn a_dark_card_face_survives_a_full_recede() {
|
||||
// Every dark palette's coverless card, at the quieter of the two tints
|
||||
// `screens::library::draw_poster_placeholder` draws — the darkest face the shelf has.
|
||||
for p in crate::library::PALETTES.iter().filter(|p| !p.light) {
|
||||
set_ink(Ink::of(p));
|
||||
let f = card_face(0.20);
|
||||
let out = apply(&recede_matrix(1.0), [f.r, f.g, f.b, 1.0]);
|
||||
for c in &out[..3] {
|
||||
assert!(
|
||||
*c > 0.05,
|
||||
"the recede crushed {}'s card face to black: {out:?}",
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
set_ink(DARK_INK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use crate::anim::{approach, entrances, springs, Entrance, EntranceAt, Spring, TRAY_C, TRAY_K};
|
||||
use crate::library::{BUMP_C, BUMP_K};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
||||
use crate::theme::{accent, fg, fill, stroke, Fonts, PanelStroke, EDGE_INSET, W};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Paint, PathBuilder, RRect, Rect};
|
||||
|
||||
@@ -93,6 +93,26 @@ const PRESS_DIP: f64 = 0.97;
|
||||
/// showing off.
|
||||
const ROW_RISE: f64 = 12.0;
|
||||
|
||||
/// The value a step left behind, and everything its half of the crossfade needs to draw.
|
||||
struct SlipPrev {
|
||||
/// Which row it belongs to — by index AND label, because the screen rebuilds the row
|
||||
/// set from scratch every frame. A row appearing or dropping above the cursor would
|
||||
/// otherwise hand this text to whatever inherited the index, i.e. slide one setting's
|
||||
/// old value across a different setting's row.
|
||||
row: usize,
|
||||
label: String,
|
||||
text: String,
|
||||
/// Where this text sits RELATIVE to the incoming one (∓[`SLIP_DP`], set from the step
|
||||
/// direction), so the pair keeps travelling the right way round even after the spring
|
||||
/// reverses through zero on an accumulated repeat.
|
||||
offset: f64,
|
||||
/// The slip position the step armed at, and so the span the crossfade divides by.
|
||||
/// Deliberately not [`SLIP_DP`]: a held repeat accumulates as far as [`SLIP_MAX`], and
|
||||
/// normalising that against the smaller constant leaves the INCOMING value at alpha 0
|
||||
/// for the first third of the travel — a fast repeat showing nothing but stale text.
|
||||
arm: f64,
|
||||
}
|
||||
|
||||
/// The focus list: authoritative cursor, spring recoil at the ends, a scroll offset
|
||||
/// that chases the focused row, and a per-row focus amount for the scale/tint ease.
|
||||
pub(crate) struct MenuList {
|
||||
@@ -113,11 +133,9 @@ pub(crate) struct MenuList {
|
||||
/// step — its velocity is what makes held repeats accumulate into one travel instead of
|
||||
/// restarting the crossfade.
|
||||
slip: Spring,
|
||||
/// The value the slip is sliding OUT: `(row, text, offset)`. The offset is where that
|
||||
/// text sits RELATIVE to the incoming one (∓[`SLIP_DP`], set from the step direction),
|
||||
/// so the pair keeps travelling the right way round even after the spring reverses
|
||||
/// through zero on an accumulated repeat.
|
||||
slip_prev: Option<(usize, String, f64)>,
|
||||
/// The value the slip is sliding OUT, and where — `None` whenever nothing is mid-step,
|
||||
/// which is also what tells every OTHER row it has no crossfade to draw.
|
||||
slip_prev: Option<SlipPrev>,
|
||||
/// Direction of the step the list last emitted, consumed by the next render. The list
|
||||
/// arms the slip ITSELF by noticing the value changed, so no screen has to report it —
|
||||
/// and a refused adjust (the value didn't move) correctly produces no slip at all.
|
||||
@@ -316,20 +334,40 @@ impl MenuList {
|
||||
// Arm the value slip. A step went out (`step_dir`) AND the value under the cursor
|
||||
// actually changed — comparing what we DREW last frame against what the screen just
|
||||
// handed us is what makes a refused adjust produce no motion at all, without any
|
||||
// screen having to report whether its edit landed.
|
||||
// screen having to report whether its edit landed. The direction is taken
|
||||
// unconditionally so a step can never leak into a later frame, but only a row that
|
||||
// ADVERTISES stepping may slip: A and a pointer press arm one whatever the row is, so
|
||||
// a row whose value merely re-reads differently under the press — a profile's "Pinned
|
||||
// to 3 hosts" as the count changes — would otherwise slide its value sideways with no
|
||||
// chevrons on screen ever having promised that it steps.
|
||||
let dir = std::mem::take(&mut self.step_dir);
|
||||
if dir != 0 && !reduce {
|
||||
let now = rows
|
||||
.get(self.cursor)
|
||||
.and_then(|r| r.value.as_deref())
|
||||
.unwrap_or_default();
|
||||
let stepped = if dir != 0 && !reduce {
|
||||
rows.get(self.cursor).filter(|r| r.adjustable)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(row) = stepped {
|
||||
let now = row.value.as_deref().unwrap_or_default();
|
||||
if self.shown.get(self.cursor).is_some_and(|p| p != now) {
|
||||
let prev = self.shown[self.cursor].clone();
|
||||
// ADD rather than set, and never touch `vel`: two fast presses accumulate
|
||||
// into one accelerating travel instead of restarting the crossfade.
|
||||
self.slip.pos =
|
||||
(self.slip.pos + SLIP_DP * f64::from(dir)).clamp(-SLIP_MAX, SLIP_MAX);
|
||||
self.slip_prev = Some((self.cursor, prev, -SLIP_DP * f64::from(dir)));
|
||||
// A step that exactly cancels one still in flight leaves no travel to
|
||||
// crossfade over — and no span to normalise the fade against — so the new
|
||||
// value simply takes the row.
|
||||
self.slip_prev = if self.slip.pos == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(SlipPrev {
|
||||
row: self.cursor,
|
||||
label: row.label.clone(),
|
||||
text: prev,
|
||||
offset: -SLIP_DP * f64::from(dir),
|
||||
arm: self.slip.pos,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
self.slip.step_spec(0.0, springs::FOCUS, dt);
|
||||
@@ -480,59 +518,102 @@ impl MenuList {
|
||||
};
|
||||
let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 };
|
||||
let caret_w = if row.caret { 8.0 * k } else { 0.0 };
|
||||
// The value FIELD: a fixed right edge and a maximum width, with every string
|
||||
// right-aligned against that edge by its OWN measured width. Sharing one
|
||||
// anchor computed from the incoming value is what threw the outgoing text
|
||||
// off the row: left-aligned on someone else's alignment it started life
|
||||
// displaced by the width difference and hung that far past the field, which
|
||||
// on "PyroWave (wired LAN)" → "Automatic" is most of a hundred px.
|
||||
let vmax = row_w * 0.55;
|
||||
let vw = (fonts.measure(value, W::Medium, 15.0 * k) as f64).min(vmax);
|
||||
let vx = x0 + row_w - 16.0 * k - chevron_w - caret_w - vw;
|
||||
// The sprung slip + crossfade. `slip_prev` names its ROW, so a value that
|
||||
// changed somewhere else (a profile row appearing, a dependent row
|
||||
// re-enabling) can't drag this one's text sideways.
|
||||
let slipping = self.slip_prev.as_ref().filter(|(row, ..)| *row == i);
|
||||
let val_right = x0 + row_w - 16.0 * k - chevron_w - caret_w;
|
||||
let place = |s: &str| val_right - f64::from(fonts.measure(s, W::Medium, 15.0 * k));
|
||||
// The sprung slip + crossfade. `slip_prev` names its ROW — index and label
|
||||
// both, since an index alone is not an identity across a rebuild — so a
|
||||
// value that changed somewhere else (a profile row appearing, a dependent
|
||||
// row re-enabling) can't drag this one's text sideways.
|
||||
let slipping = self
|
||||
.slip_prev
|
||||
.as_ref()
|
||||
.filter(|p| p.row == i && p.label == row.label);
|
||||
let dx = if slipping.is_some() {
|
||||
self.slip.pos * k
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gone = (self.slip.pos.abs() / SLIP_DP).clamp(0.0, 1.0) as f32;
|
||||
// Gated on the SLIPPING row exactly as `dx` above is. There is one slip
|
||||
// spring for the whole list, so an ungated fade read the same spring on
|
||||
// every row and blanked the entire value column each time any one value
|
||||
// stepped. Signed rather than `.abs()`, so the deliberate overshoot back
|
||||
// through zero clamps to nothing instead of fading the ghost in again.
|
||||
let gone = slipping.map_or(0.0, |p| (self.slip.pos / p.arm).clamp(0.0, 1.0) as f32);
|
||||
let alpha =
|
||||
|c: skia_safe::Color4f, a: f32| skia_safe::Color4f::new(c.r, c.g, c.b, c.a * a);
|
||||
if let Some((_, prev, offset)) = slipping {
|
||||
// Head-truncate: keep the END of a long address visible while typing. Done
|
||||
// BEFORE placing, so what is right-aligned is the string actually drawn —
|
||||
// measuring the untruncated one left every long value floating short of the
|
||||
// edge by whatever the ellipsis saved.
|
||||
let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
|
||||
// Nothing else confines the pair: the widget's only clip is the LIST rect,
|
||||
// which is the full window width, leaving a couple of hundred px of open
|
||||
// background either side of the row for a sliding value to cross. Only while
|
||||
// there is travel to hide — a settled value fits the field by construction,
|
||||
// and a clip per row per frame is not free.
|
||||
if slipping.is_some() {
|
||||
canvas.save();
|
||||
canvas.clip_rect(
|
||||
Rect::from_ltrb(
|
||||
(val_right - vmax) as f32,
|
||||
r.top,
|
||||
val_right as f32,
|
||||
r.bottom,
|
||||
),
|
||||
None,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let Some(p) = slipping {
|
||||
// The value being left, sliding out the way the step came from.
|
||||
let prev_text = truncate_head(fonts, prev, W::Medium, 15.0 * k, vmax);
|
||||
let prev_text = truncate_head(fonts, &p.text, W::Medium, 15.0 * k, vmax);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&prev_text,
|
||||
vx + dx + offset * k,
|
||||
place(&prev_text) + dx + p.offset * k,
|
||||
baseline,
|
||||
W::Medium,
|
||||
15.0 * k,
|
||||
alpha(vcolor, gone),
|
||||
);
|
||||
}
|
||||
// Head-truncate: keep the END of a long address visible while typing.
|
||||
let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
&shown,
|
||||
vx + dx,
|
||||
place(&shown) + dx,
|
||||
baseline,
|
||||
W::Medium,
|
||||
15.0 * k,
|
||||
alpha(vcolor, 1.0 - gone),
|
||||
);
|
||||
if slipping.is_some() {
|
||||
canvas.restore(); // the value field
|
||||
}
|
||||
if row.caret {
|
||||
// Rides `dx` so the caret stays welded to the end of the text rather
|
||||
// than detaching from it mid-slip.
|
||||
canvas.draw_rect(
|
||||
Rect::from_xywh(
|
||||
(vx + vw + 3.0 * k) as f32,
|
||||
(val_right + 3.0 * k + dx) as f32,
|
||||
(cy - 9.0 * k) as f32,
|
||||
(2.0 * k) as f32,
|
||||
(18.0 * k) as f32,
|
||||
),
|
||||
&Paint::new(accent(1.0), None),
|
||||
&fill(accent(1.0)),
|
||||
);
|
||||
}
|
||||
if row.adjustable && f > 0.01 {
|
||||
let alpha = 0.6 * f as f32;
|
||||
chevron(canvas, vx - 11.0 * k, cy, 4.0 * k, true, alpha);
|
||||
// Drawn after, and outside the field's clip: the chevrons frame the
|
||||
// value, so a value on the move passes UNDER them.
|
||||
chevron(canvas, place(&shown) - 11.0 * k, cy, 4.0 * k, true, alpha);
|
||||
chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha);
|
||||
}
|
||||
}
|
||||
@@ -566,7 +647,34 @@ pub(crate) struct TabStrip {
|
||||
pills: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// A pill's label size and its padding either side, in design units.
|
||||
const PILL_TEXT: f64 = 13.0;
|
||||
const PILL_PAD_X: f64 = 13.0;
|
||||
/// Air between two pills.
|
||||
const PILL_GAP: f64 = 7.0;
|
||||
|
||||
/// Each pill's width and the run's total, in device px.
|
||||
///
|
||||
/// Shared with [`TabStrip::width`] rather than computed twice, because a caller that
|
||||
/// RIGHT-ALIGNS a strip has to know the run's width before the strip draws itself — and a
|
||||
/// second copy of this arithmetic would put the measured edge somewhere the drawn edge is not.
|
||||
fn pill_widths(labels: &[&str], fonts: &Fonts, k: f64) -> (Vec<f64>, f64) {
|
||||
let size = PILL_TEXT * k;
|
||||
let widths: Vec<f64> = labels
|
||||
.iter()
|
||||
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * PILL_PAD_X * k)
|
||||
.collect();
|
||||
let total = widths.iter().sum::<f64>() + PILL_GAP * k * (labels.len().saturating_sub(1)) as f64;
|
||||
(widths, total)
|
||||
}
|
||||
|
||||
impl TabStrip {
|
||||
/// How wide this run of pills draws. For a caller placing the strip against a TRAILING
|
||||
/// edge, where the position depends on the width.
|
||||
pub(crate) fn width(labels: &[&str], fonts: &Fonts, k: f64) -> f64 {
|
||||
pill_widths(labels, fonts, k).1
|
||||
}
|
||||
|
||||
pub(crate) fn new() -> TabStrip {
|
||||
TabStrip {
|
||||
indicator: None,
|
||||
@@ -588,7 +696,8 @@ impl TabStrip {
|
||||
p.press().then(|| p.pick(&self.pills)).flatten()
|
||||
}
|
||||
|
||||
/// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already
|
||||
/// Draw the pills along the leading edge of `rect`'s top band, at the same
|
||||
/// [`EDGE_INSET`] the heading above them uses. Returns nothing — the caller already
|
||||
/// knows the band is [`TAB_STRIP_H`] tall.
|
||||
#[allow(clippy::too_many_arguments)] // the crate's render signature, same as MenuList's
|
||||
pub(crate) fn render(
|
||||
@@ -604,16 +713,24 @@ impl TabStrip {
|
||||
if labels.is_empty() {
|
||||
return;
|
||||
}
|
||||
let size = 13.0 * k;
|
||||
let pad_x = 13.0 * k;
|
||||
let gap = 7.0 * k;
|
||||
let pill_h = 30.0 * k;
|
||||
let widths: Vec<f64> = labels
|
||||
.iter()
|
||||
.map(|l| f64::from(fonts.measure(l, W::SemiBold, size)) + 2.0 * pad_x)
|
||||
.collect();
|
||||
let total: f64 = widths.iter().sum::<f64>() + gap * (labels.len() - 1) as f64;
|
||||
let mut x = f64::from(rect.left) + (f64::from(rect.width()) - total) / 2.0;
|
||||
let size = PILL_TEXT * k;
|
||||
let (widths, total) = pill_widths(labels, fonts, k);
|
||||
let gap = PILL_GAP * k;
|
||||
// Leading, under the heading it belongs to — a strip centred beneath a left-aligned
|
||||
// title reads as two unrelated pieces of chrome. Both callers hand this widget the
|
||||
// full content width, so the inset is measured here rather than baked into the band.
|
||||
//
|
||||
// Written as a clamp rather than a branch so it degrades CONTINUOUSLY as the window
|
||||
// narrows: full inset while both fit, then centred, then hard against the leading
|
||||
// edge once the run is wider than the band. That last case spends its overflow on
|
||||
// the right, which is the side the user has not looked at yet — losing the first
|
||||
// section instead would cost them the one the strip is read from. Neither reference
|
||||
// client can reach it (both scroll their strip) and the narrowest window the console
|
||||
// is tested at still clears the first case.
|
||||
let inset = EDGE_INSET * k;
|
||||
let slack = f64::from(rect.width()) - total;
|
||||
let mut x = f64::from(rect.left) + inset.min((slack / 2.0).max(0.0));
|
||||
let top = f64::from(rect.top) + 2.0 * k;
|
||||
|
||||
// Where the highlight wants to be, then the eased position it actually draws at.
|
||||
@@ -698,11 +815,8 @@ 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(fg(alpha), None);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width((1.8 * r / 4.0) as f32);
|
||||
let mut p = stroke(fg(alpha), (1.8 * r / 4.0) as f32);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_anti_alias(true);
|
||||
let mut path = PathBuilder::new();
|
||||
path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
|
||||
path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
|
||||
@@ -927,7 +1041,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);
|
||||
self.keys.push((kr, *key));
|
||||
let fill = if focused {
|
||||
let face = if focused {
|
||||
let mut b = accent(1.0);
|
||||
if self.key_flash > 0.02 {
|
||||
// A just-typed key flashes brighter, then eases back.
|
||||
@@ -945,7 +1059,7 @@ impl Keyboard {
|
||||
};
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32),
|
||||
&Paint::new(fill, None),
|
||||
&fill(face),
|
||||
);
|
||||
// The focused key is filled with the accent, so its letter needs ink that
|
||||
// reads on THAT, not on the field.
|
||||
@@ -995,13 +1109,12 @@ impl Keyboard {
|
||||
}
|
||||
}
|
||||
|
||||
/// A round-capped, round-joined stroke — the console's hand-drawn marks (chevrons, ticks,
|
||||
/// the pad silhouettes) all want those, so they get their own wrapper over `theme::stroke`.
|
||||
fn stroke_paint(ink: skia_safe::Color4f, width: f32) -> Paint {
|
||||
let mut p = Paint::new(ink, None);
|
||||
p.set_style(skia_safe::PaintStyle::Stroke);
|
||||
p.set_stroke_width(width);
|
||||
let mut p = stroke(ink, width);
|
||||
p.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||
p.set_stroke_join(skia_safe::PaintJoin::Round);
|
||||
p.set_anti_alias(true);
|
||||
p
|
||||
}
|
||||
|
||||
@@ -1189,6 +1302,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The strip stands on the same column as the heading above it — [`EDGE_INSET`], scaled
|
||||
/// like every other design unit — and gives that column up only in the order a shrinking
|
||||
/// window forces: inset, then centred, then flush. Asserted as that ordering rather than
|
||||
/// against three measured x's, so it still means something when a tab is renamed.
|
||||
#[test]
|
||||
fn tab_strip_stands_on_the_edge_inset_and_gives_it_up_in_order() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((1400, 160)).unwrap();
|
||||
let dt = 1.0 / 60.0;
|
||||
// What the seven sections actually measure, so nothing below is a hardcoded pixel.
|
||||
let mut run = |w: f32, k: f64| {
|
||||
let rect = Rect::from_xywh(0.0, 0.0, w, (TAB_STRIP_H * k) as f32);
|
||||
let mut strip = TabStrip::new();
|
||||
strip.render(surface.canvas(), rect, &TABS, 0, &fonts, k, dt);
|
||||
let first = strip.pill(0).expect("the first section was drawn");
|
||||
let last = strip
|
||||
.pill(TABS.len() - 1)
|
||||
.expect("the last section was drawn");
|
||||
(rect, f64::from(first.left), f64::from(last.right))
|
||||
};
|
||||
|
||||
// Room for both insets: the strip starts exactly on the heading's column, at every
|
||||
// scale, and still ends inside the band.
|
||||
for k in [0.75, 1.0, 2.0] {
|
||||
let (rect, left, right) = run(1400.0, k);
|
||||
assert!(
|
||||
(left - (f64::from(rect.left) + EDGE_INSET * k)).abs() < 0.5,
|
||||
"k={k}: strip starts at {left}, not on the {} column",
|
||||
EDGE_INSET * k
|
||||
);
|
||||
assert!(
|
||||
right <= f64::from(rect.right),
|
||||
"k={k}: strip overran its band"
|
||||
);
|
||||
}
|
||||
|
||||
let (_, wide_left, wide_right) = run(1400.0, 1.0);
|
||||
let total = wide_right - wide_left;
|
||||
|
||||
// Too narrow for both insets but still wider than the run: it centres, which is the
|
||||
// only placement that does not spend the whole shortfall on one edge.
|
||||
let (rect, left, right) = run((total + EDGE_INSET) as f32, 1.0);
|
||||
assert!(
|
||||
(left - f64::from(rect.left) - (f64::from(rect.right) - right)).abs() < 0.5,
|
||||
"a squeezed strip should sit even: {left} in from the left, {} from the right",
|
||||
f64::from(rect.right) - right
|
||||
);
|
||||
|
||||
// Wider than the band: flush against the leading edge, overflowing right only.
|
||||
let (rect, left, right) = run((total - 40.0) as f32, 1.0);
|
||||
assert!(
|
||||
(left - f64::from(rect.left)).abs() < 0.5,
|
||||
"an overflowing strip should go flush left, not to {left}"
|
||||
);
|
||||
assert!(
|
||||
right > f64::from(rect.right),
|
||||
"the run was supposed to overflow"
|
||||
);
|
||||
}
|
||||
|
||||
fn value_row(value: &str) -> Vec<RowSpec> {
|
||||
vec![RowSpec {
|
||||
header: None,
|
||||
@@ -1201,6 +1374,186 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
/// One row's pixel region to compare across renders: `(row index, (x0, x1), (y0, y1))`.
|
||||
type Band = (usize, (i32, i32), (i32, i32));
|
||||
|
||||
/// A whole COLUMN of steppable value rows — the shape the settings screen actually
|
||||
/// draws, and the one thing a single-row list cannot stand in for: on a one-row list the
|
||||
/// slipping row is the only row, so a crossfade that leaks onto its neighbours has no
|
||||
/// neighbours to leak onto.
|
||||
fn value_rows(values: &[&str]) -> Vec<RowSpec> {
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| RowSpec {
|
||||
header: None,
|
||||
label: format!("Option {i}"),
|
||||
value: Some((*v).to_string()),
|
||||
value_dim: false,
|
||||
caret: false,
|
||||
adjustable: true,
|
||||
enabled: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_back(surface: &mut skia_safe::Surface, w: i32, h: i32) -> Vec<u8> {
|
||||
let mut px = vec![0u8; (w * h * 4) as usize];
|
||||
let info = skia_safe::ImageInfo::new_n32_premul((w, h), None);
|
||||
assert!(
|
||||
surface.read_pixels(&info, &mut px, (w * 4) as usize, (0, 0)),
|
||||
"raster surface read-back"
|
||||
);
|
||||
px
|
||||
}
|
||||
|
||||
/// How many bytes of one band of two readbacks differ. A COUNT rather than the two
|
||||
/// slices, because `assert_eq!` on a megapixel prints a megapixel.
|
||||
fn band_diff(a: &[u8], b: &[u8], w: i32, x: (i32, i32), y: (i32, i32)) -> usize {
|
||||
let mut differing = 0;
|
||||
for row in y.0..y.1 {
|
||||
let base = (row * w * 4) as usize;
|
||||
let span = base + x.0 as usize * 4..base + x.1 as usize * 4;
|
||||
differing += a[span.clone()]
|
||||
.iter()
|
||||
.zip(&b[span])
|
||||
.filter(|(p, q)| p != q)
|
||||
.count();
|
||||
}
|
||||
differing
|
||||
}
|
||||
|
||||
/// Stepping ONE row's value leaves every other row's pixels exactly as they were.
|
||||
///
|
||||
/// The list owns a SINGLE slip spring, so the crossfade alpha derived from it has to be
|
||||
/// gated on the slipping row's identity the way its sibling displacement already is.
|
||||
/// Ungated, one press dropped EVERY value in the list to alpha 0 and faded them back
|
||||
/// over ~200 ms while the labels sat still — a whole settings column blinking on one
|
||||
/// step, and worse on a held repeat, where the accumulated slip pins the alpha at zero
|
||||
/// for several consecutive frames.
|
||||
#[test]
|
||||
fn stepping_one_value_leaves_the_other_rows_alone() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let (w, h) = (900, 600);
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||
let rect = Rect::from_xywh(0.0, 0.0, w as f32, h as f32);
|
||||
let clear = skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0);
|
||||
let dt = 1.0 / 60.0;
|
||||
let before = value_rows(&["Native", "Automatic", "20 Mbps", "Balanced", "On", "Off"]);
|
||||
let mut list = MenuList::new();
|
||||
// Settled first: the mount entrance, the scroll and the focus ease all travel on
|
||||
// their own, and the claim under test is that nothing ELSE moves.
|
||||
for _ in 0..240 {
|
||||
surface.canvas().clear(clear);
|
||||
list.render(surface.canvas(), rect, &before, &fonts, 1.0, dt, true);
|
||||
}
|
||||
let bands: Vec<Band> = (1..before.len())
|
||||
.map(|i| {
|
||||
let r = list.row_rect(i).expect("every row is on screen");
|
||||
(
|
||||
i,
|
||||
(r.left as i32, r.right as i32),
|
||||
(r.top as i32, r.bottom as i32),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let settled = read_back(&mut surface, w, h);
|
||||
|
||||
// The bands really are where those values live: a row whose value string differs
|
||||
// draws differently in exactly this region, so the equality asserted below is not a
|
||||
// comparison of two identical patches of background.
|
||||
let mut probe = MenuList::new();
|
||||
let probed = value_rows(&["Native", "Automatic", "20 Mbps", "Native", "On", "Off"]);
|
||||
for _ in 0..240 {
|
||||
surface.canvas().clear(clear);
|
||||
probe.render(surface.canvas(), rect, &probed, &fonts, 1.0, dt, true);
|
||||
}
|
||||
let probe_px = read_back(&mut surface, w, h);
|
||||
let (_, px_x, px_y) = bands[2];
|
||||
assert!(
|
||||
band_diff(&probe_px, &settled, w, px_x, px_y) > 0,
|
||||
"row 3's band must contain row 3's value"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
list.menu(MenuEvent::Move(MenuDir::Right), before.len()).0,
|
||||
ListMsg::Adjust(1)
|
||||
);
|
||||
let after = value_rows(&[
|
||||
"Match window",
|
||||
"Automatic",
|
||||
"20 Mbps",
|
||||
"Balanced",
|
||||
"On",
|
||||
"Off",
|
||||
]);
|
||||
let mut armed = false;
|
||||
for frame in 0..12 {
|
||||
surface.canvas().clear(clear);
|
||||
list.render(surface.canvas(), rect, &after, &fonts, 1.0, dt, true);
|
||||
armed |= list.slip_prev.is_some();
|
||||
let px = read_back(&mut surface, w, h);
|
||||
for (i, bx, by) in &bands {
|
||||
assert_eq!(
|
||||
band_diff(&px, &settled, w, *bx, *by),
|
||||
0,
|
||||
"row {i} redrew on frame {frame} of a step made on row 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(armed, "the step must have animated, or this proves nothing");
|
||||
}
|
||||
|
||||
/// A stepped value stays inside its field, however wide the value it is leaving.
|
||||
///
|
||||
/// Both halves of the crossfade used to share ONE anchor — the right-alignment computed
|
||||
/// from the INCOMING string's width — so the outgoing text was left-aligned on someone
|
||||
/// else's alignment: it appeared displaced by the width difference and hung that far
|
||||
/// past the field, at full alpha, for the whole spring. Nothing clipped it either; the
|
||||
/// widget's only clip is the LIST rect, which is the full window width. "PyroWave (wired
|
||||
/// LAN)" → "AV1" is most of a hundred px of ghost text sitting on the background.
|
||||
///
|
||||
/// Asserted on the band OUTSIDE the field, whose ink — the › chevron, the panel's own
|
||||
/// edge — is fixed once the row has settled, so any change there is text that escaped.
|
||||
#[test]
|
||||
fn a_stepped_value_stays_inside_its_field() {
|
||||
let fonts = crate::theme::build_fonts().unwrap();
|
||||
let (w, h) = (900, 600);
|
||||
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap();
|
||||
let rect = Rect::from_xywh(0.0, 0.0, w as f32, h as f32);
|
||||
let clear = skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0);
|
||||
let dt = 1.0 / 60.0;
|
||||
let mut list = MenuList::new();
|
||||
let wide = value_row("PyroWave (wired LAN)");
|
||||
for _ in 0..240 {
|
||||
surface.canvas().clear(clear);
|
||||
list.render(surface.canvas(), rect, &wide, &fonts, 1.0, dt, true);
|
||||
}
|
||||
let r = list.row_rect(0).expect("the row is on screen");
|
||||
// The field's right edge, as `render` computes it: the row's 16 dp gutter plus the
|
||||
// 18 dp the chevrons reserve, at k = 1. Two px of slack for the clip's own edge.
|
||||
let field_right = (f64::from(r.right) - 16.0 - 18.0).ceil() as i32;
|
||||
let outside = (field_right + 2, w);
|
||||
let band_y = (r.top as i32, r.bottom.ceil() as i32);
|
||||
let settled = read_back(&mut surface, w, h);
|
||||
|
||||
list.menu(MenuEvent::Move(MenuDir::Right), 1);
|
||||
let narrow = value_row("AV1");
|
||||
let mut armed = false;
|
||||
for frame in 0..16 {
|
||||
surface.canvas().clear(clear);
|
||||
list.render(surface.canvas(), rect, &narrow, &fonts, 1.0, dt, true);
|
||||
armed |= list.slip_prev.is_some();
|
||||
let px = read_back(&mut surface, w, h);
|
||||
assert_eq!(
|
||||
band_diff(&px, &settled, w, outside, band_y),
|
||||
0,
|
||||
"value ink escaped the field on frame {frame}"
|
||||
);
|
||||
}
|
||||
assert!(armed, "the step must have animated, or this proves nothing");
|
||||
}
|
||||
|
||||
/// The value slip: armed only when a step actually CHANGED the value, and always
|
||||
/// settling back onto the row's own position. A slip that never returns to identity
|
||||
/// leaves the value permanently offset, which is the bug this shape can have.
|
||||
|
||||
@@ -274,11 +274,10 @@ client, which reads this setting like any other connect. The console home also o
|
||||
explicit action on an offline host, whatever the toggle says. See
|
||||
[Wake-on-LAN](/docs/wake-on-lan).
|
||||
|
||||
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
|
||||
a paired host's games and launch one directly; the Windows app still labels it experimental. The
|
||||
console home has the toggle too, and it governs the desktop clients that share the store — the
|
||||
console's own **Library** button is offered on any paired host either way. See
|
||||
[Game library](/docs/game-library).
|
||||
**Show game library** — *Apple and Android only, default: on.* Browse a paired host's games and
|
||||
launch one directly. The Linux and Windows apps have nothing to switch on — **Browse library…** sits
|
||||
on every paired host's card — and neither does the console home, whose **Library** button was always
|
||||
offered on any paired host. See [Game library](/docs/game-library).
|
||||
|
||||
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
|
||||
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
|
||||
@@ -350,8 +349,8 @@ stay global and **cannot be put in a settings profile**:
|
||||
preference and can live in a profile; which pad you hold cannot. **Forward controllers** is a
|
||||
preference too, and does live in a profile — a work profile can decline to forward what a game
|
||||
profile forwards.
|
||||
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
|
||||
not about how a given host is streamed.
|
||||
- **Auto-wake on connect**, and **Show game library** where it still exists (the Apple and Android
|
||||
apps) — decisions about this device and this network, not about how a given host is streamed.
|
||||
- Everything under **Interface** — **Gamepad-optimized browsing**, **Show it** and **Background**.
|
||||
How this client looks and which layout it wears has nothing to do with how a host streams to it,
|
||||
so binding them to a host would only make the same device change appearance depending on what it
|
||||
|
||||
@@ -133,11 +133,10 @@ confirmation open as usual, but the host refuses the change and the entry stays
|
||||
Whatever the surface, the client sends only an **id**. The host looks that id up in its own library
|
||||
and runs what it already knows about the title, so a client can never hand the host a command to run.
|
||||
|
||||
- **Native clients** — the browser is a per-device setting in **Settings → Library**, and it needs a
|
||||
**paired** host. It is **off by default on the Linux and Windows clients** ("Show game library",
|
||||
or "Show game library (experimental)" on Windows) and **on by default on macOS, iOS, iPadOS, tvOS
|
||||
and Android**. Turn it on and a paired host's card offers **Browse library…** (**Browse Library…**
|
||||
on Apple); pick a title and the stream starts with the host launching it. See
|
||||
- **Native clients** — the browser needs a **paired** host, and that is the only condition: a paired
|
||||
host's card offers **Browse library…** (**Browse Library…** on Apple) with nothing to switch on
|
||||
first. Pick a title and the stream starts with the host launching it. The Apple and Android apps
|
||||
keep a **Show game library** switch, on by default, for turning it off. See
|
||||
[Client settings](/docs/client-settings).
|
||||
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
|
||||
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
|
||||
|
||||
@@ -41,9 +41,9 @@ into a "no" on your machine:
|
||||
and deliberately so: the host resolves the chroma *before* the Welcome and names the losing gate
|
||||
in its log, and the client's stats overlay prints `4:4:4→4:2:0` rather than letting you assume
|
||||
you got what you asked for.
|
||||
- **Default on / opt-in / operator-gated** — HDR and 10-bit are attempted by default; the game
|
||||
library is off by default on the desktop clients; the shared clipboard is off on the host until
|
||||
an operator turns it on.
|
||||
- **Default on / opt-in / operator-gated** — HDR and 10-bit are attempted by default; lossless audio
|
||||
stays on Opus until a client asks for it; the shared clipboard is off on the host until an
|
||||
operator turns it on.
|
||||
|
||||
One more thing a table cannot show: a few capabilities **latch off for the rest of the host
|
||||
process** after a failure — a lost HDR negotiation, a repeatedly dying zero-copy import worker, a
|
||||
@@ -395,8 +395,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
|
||||
| Client | Profiles | `punktfunk://` links | Game library | Speed test | Wake-on-LAN | Updates itself |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Linux desktop | ✅ | ✅ | ⚠️ ¹ | ✅ | ✅ | ⚠️ ² |
|
||||
| Windows desktop | ✅ | ✅ | ⚠️ ¹ | ✅ | ✅ | ❌ ³ |
|
||||
| Linux desktop | ✅ | ✅ | ✅ ¹ | ✅ | ✅ | ⚠️ ² |
|
||||
| Windows desktop | ✅ | ✅ | ✅ ¹ | ✅ | ✅ | ❌ ³ |
|
||||
| macOS | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
| iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
| Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
|
||||
@@ -405,10 +405,9 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
|
||||
| `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² |
|
||||
|
||||
1. **Off by default.** Turn on the library in Preferences before "Browse library…" appears. It also
|
||||
needs a paired host to return anything — Windows hides the entry until the host is paired; the
|
||||
GTK client shows it either way and the fetch then fails. See
|
||||
[Game library](/docs/game-library).
|
||||
1. **Any paired host.** "Browse library…" sits on a saved host's card with nothing to turn on first.
|
||||
It stays hidden on a host that is only trusted, not yet paired: the fetch authenticates with the
|
||||
pairing identity and would come back refused. See [Game library](/docs/game-library).
|
||||
2. One of two clients with a self-updater (the Decky plugin is the other), and what `--apply-update`
|
||||
can do depends on how you
|
||||
installed it: Flatpak updates itself, system packages go through a packaged helper, image-based
|
||||
|
||||
Reference in New Issue
Block a user