From 63a4f583b926e17923cc5302ad3b96a9b3c6ded8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:40:53 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(console):=20profiles=20reach=20the=20g?= =?UTF-8?q?amepad=20UI=20=E2=80=94=20pinned=20cards,=20pin=20management,?= =?UTF-8?q?=20settings=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skia console now renders a pinned profile card after its host's primary tile (KnownHost::pinned_profiles resolved by the service thread), connects with that profile as a one-off via the existing effective_settings resolver, and shows the bound default profile on the primary tile. The settings screen gains a trailing Profiles section — one row per catalog profile with a live pin count — whose activation opens a pin-to-hosts screen; toggles ride the new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the same field the CLI resolves for Decky's host list). Profiles themselves stay desktop-authored (design client-settings-profiles.md §5.2a, §5.4). --- clients/session/src/console.rs | 98 ++++++- crates/pf-console-ui/src/lib.rs | 4 +- crates/pf-console-ui/src/model.rs | 35 ++- crates/pf-console-ui/src/screens.rs | 9 + crates/pf-console-ui/src/screens/home.rs | 128 ++++++++- crates/pf-console-ui/src/screens/library.rs | 2 + crates/pf-console-ui/src/screens/pair.rs | 3 + crates/pf-console-ui/src/screens/pin_hosts.rs | 266 ++++++++++++++++++ crates/pf-console-ui/src/screens/settings.rs | 256 +++++++++++++++-- crates/pf-console-ui/src/shell.rs | 9 +- crates/pf-console-ui/src/shell/tests.rs | 2 + crates/pf-console-ui/src/skia_overlay.rs | 5 +- crates/pf-presenter/src/overlay.rs | 5 + 13 files changed, 774 insertions(+), 48 deletions(-) create mode 100644 crates/pf-console-ui/src/screens/pin_hosts.rs diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 99d6687b..a424d70a 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 { can_wake: false, last_used: k.and_then(|h| h.last_used), os: k.map(|h| h.os.clone()).unwrap_or_default(), + pin: None, + bound_profile: None, }; let label = row.name.clone(); if k.is_none() { seed = Some(row.clone()); } if row.paired { - (ConsoleEntry::Library(row), Some(label)) + (ConsoleEntry::Library(Box::new(row)), Some(label)) } else { (ConsoleEntry::Home, Some(label)) } } None if fake => { let row = fake_host_row(); - (ConsoleEntry::Library(row), None) + (ConsoleEntry::Library(Box::new(row)), None) } None => (ConsoleEntry::Home, None), }; @@ -207,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 { launch, title, request_access, + profile, } => { let Some(pin) = trust::parse_hex32(&fp_hex) else { // Connect (and request-access) pin the host's advertised fingerprint; @@ -221,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 { // have changed the defaults since the last stream, and the host may carry // a profile binding. Console (and therefore Decky, which spawns this // binary) honors bindings with no console-side work — the resolver is the - // same one `--connect` goes through. No one-off here: picking a profile is - // a desktop-shell affordance in v1, pinned cards are the console's. - let (settings, profile) = trust::effective_settings(&addr, port, None); + // same one `--connect` goes through. A pinned card's connect arrives as a + // one-off profile id; the resolver prefers it over the binding, and a + // dangling id falls back to the defaults without blocking the connect. + let (settings, profile) = + trust::effective_settings(&addr, port, profile.as_deref()); let mut params = session_params( &settings, profile.map(|p| p.name), @@ -303,6 +308,8 @@ fn fake_host_row() -> HostRow { can_wake: false, last_used: None, os: "linux/arch/steamos".into(), + pin: None, + bound_profile: None, } } @@ -506,6 +513,38 @@ impl ServiceState { ConsoleCmd::Probe => { self.last_probe = Instant::now() - Duration::from_secs(60); } + ConsoleCmd::SetPin { + key, + profile_id, + pin, + } => { + // Presentation only (design §5.2a): order = card order, appended at the + // end; never touches `profile_id` (the default binding). Idempotent, so + // a repeated press inside one refresh window can't double-pin. + let mut known = trust::KnownHosts::load(); + let idx = known + .hosts + .iter() + .position(|h| !h.fp_hex.is_empty() && h.fp_hex == key) + .or_else(|| { + let (addr, port) = key.rsplit_once(':')?; + known.index_by_addr(addr, port.parse().ok()?) + }); + let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else { + tracing::warn!(%key, "pin toggle for an unknown host — ignoring"); + return; + }; + if pin && !h.pinned_profiles.contains(&profile_id) { + h.pinned_profiles.push(profile_id); + } else if !pin { + h.pinned_profiles.retain(|id| *id != profile_id); + } + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving known hosts"); + } + // `run` refreshes the rows right after this drain, so the carousel and + // the pin screen reflect the new card within the same service pass. + } } } @@ -544,12 +583,21 @@ impl ServiceState { }) } - /// The console home's rows: saved hosts (most recent first), then - /// discovered-but-unsaved ones, then a still-uncovered `--browse` seed. + /// The console home's rows: saved hosts (most recent first) — each followed by its + /// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a + /// still-uncovered `--browse` seed. fn rows(&self) -> Vec { let known = trust::KnownHosts::load(); + let catalog = pf_client_core::profiles::ProfilesFile::load(); let probed = self.probed.lock().unwrap(); - let mut rows: Vec = known + let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip { + id: p.id.clone(), + name: p.name.clone(), + accent: p.accent.clone(), + }; + // Primary rows paired with their pinned cards, so the sort below can order hosts + // while every host's cards stay glued behind its primary tile. + let mut saved: Vec<(HostRow, Vec)> = known .hosts .iter() .map(|h| { @@ -563,8 +611,8 @@ impl ServiceState { || (d.addr == h.addr && d.port == h.port) }); let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false); - HostRow { - key, + let row = HostRow { + key: key.clone(), name: host_display_name(&h.name, &h.addr), addr: h.addr.clone(), port: h.port, @@ -581,10 +629,34 @@ impl ServiceState { .filter(|d| !d.os.is_empty()) .map(|d| d.os.clone()) .unwrap_or_else(|| h.os.clone()), - } + pin: None, + bound_profile: h + .profile_id + .as_deref() + .and_then(|id| catalog.find_by_id(id)) + .map(chip), + }; + // A pinned card shares the primary tile's live state; its key rides the + // profile id behind a NUL (impossible in a fingerprint or `addr:port`), + // so cursor-follow and the wake path address the card itself. + let pins = h + .resolved_pins(&catalog) + .into_iter() + .map(|p| HostRow { + key: format!("{key}\0{}", p.id), + pin: Some(chip(p)), + bound_profile: None, + ..row.clone() + }) + .collect(); + (row, pins) }) .collect(); - rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name))); + saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name))); + let mut rows: Vec = saved + .into_iter() + .flat_map(|(row, pins)| std::iter::once(row).chain(pins)) + .collect(); let mut extra: Vec = self .discovered @@ -612,6 +684,8 @@ impl ServiceState { can_wake: false, last_used: None, os: d.os.clone(), + pin: None, + bound_profile: None, }) .collect(); extra.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index 4194b2b0..933ae725 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -35,7 +35,9 @@ mod widgets; #[cfg(any(target_os = "linux", windows))] pub use library::{LibraryGame, LibraryPhase, LibraryShared}; #[cfg(any(target_os = "linux", windows))] -pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; +pub use model::{ + ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus, +}; #[cfg(any(target_os = "linux", windows))] pub use shell::ConsoleOptions; #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index 3d810df2..ac5b0102 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -7,9 +7,20 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a): +/// the resolved name and accent of a catalog entry, keyed by its stable id. The service +/// thread resolves these against the catalog; the shell never opens the profiles file. +#[derive(Clone, Debug, PartialEq)] +pub struct ProfileChip { + pub id: String, + pub name: String, + /// `#RRGGBB`, the catalog's optional tint for pinned cards. + pub accent: Option, +} + /// One row on the console home carousel — a saved host, a discovered-but-unsaved one, -/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread; -/// the shell renders it verbatim. +/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by +/// the service thread; the shell renders it verbatim. #[derive(Clone, Debug, PartialEq)] pub struct HostRow { /// Stable identity across refreshes: the pinned fingerprint when known, else @@ -35,6 +46,14 @@ pub struct HostRow { /// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a /// follow-up — the Skia glyph set doesn't exist yet. pub os: String, + /// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right + /// after its host's primary tile, sharing its live state, that connects with THIS + /// profile. `None` = the host's primary tile. + pub pin: Option, + /// The primary tile's default-profile chip: the profile bound as this host's default + /// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses. + /// Always `None` on pinned rows — there the profile IS `pin`. + pub bound_profile: Option, } /// The pairing ceremony's observable state (one at a time — the ceremony is modal). @@ -143,6 +162,16 @@ pub enum ConsoleCmd { CancelWake, /// Sweep reachability now (the home screen refreshes its presence pips). Probe, + /// Pin (or unpin) a profile as an extra connect card on a saved host + /// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key + /// (fingerprint or `addr:port`); presentation only — never touches the host's + /// default binding or the profile itself. Idempotent: re-pinning a pinned profile + /// (or unpinning an absent one) is a no-op. + SetPin { + key: String, + profile_id: String, + pin: bool, + }, } /// The overlay→binary command queue. A plain deque under the same locking discipline as @@ -184,6 +213,8 @@ mod tests { can_wake: false, last_used: None, os: String::new(), + pin: None, + bound_profile: None, }; shared.set_hosts(vec![row.clone()]); let g1 = shared.hosts_gen(); diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index 7c7e3232..a7e2730e 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -7,6 +7,7 @@ pub(crate) mod add_host; pub(crate) mod home; pub(crate) mod library; pub(crate) mod pair; +pub(crate) mod pin_hosts; pub(crate) mod settings; use crate::glyphs::Hint; @@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent { /// shell shows a "waiting for approval" takeover instead of "connecting", and the /// binary parks on a long budget and persists the host as paired once let in. pub request_access: bool, + /// One-off settings-profile id for this launch (a pinned card's connect); `None` + /// keeps the host's default binding. + pub profile: Option, } pub(crate) enum Nav { @@ -91,6 +95,7 @@ pub(crate) enum Screen { Settings(settings::SettingsScreen), AddHost(add_host::AddHostScreen), Pair(pair::PairScreen), + PinHosts(pin_hosts::PinHostsScreen), } impl Screen { @@ -106,6 +111,7 @@ impl Screen { Screen::Settings(s) => s.menu(ev, ctx, fx), Screen::AddHost(s) => s.menu(ev, ctx, fx), Screen::Pair(s) => s.menu(ev, ctx, fx), + Screen::PinHosts(s) => s.menu(ev, ctx, fx), } } @@ -152,6 +158,7 @@ impl Screen { Screen::Settings(_) => "Settings".into(), Screen::AddHost(_) => "Add Host".into(), Screen::Pair(s) => format!("Pair with {}", s.host_name()), + Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()), } } @@ -162,6 +169,7 @@ impl Screen { Screen::Settings(s) => s.hints(ctx), Screen::AddHost(s) => s.hints(ctx), Screen::Pair(s) => s.hints(ctx), + Screen::PinHosts(s) => s.hints(ctx), } } @@ -183,6 +191,7 @@ impl Screen { Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx), Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx), Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx), + Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx), } } } diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index bc97dc49..482e6213 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -94,13 +94,19 @@ impl HomeScreen { Some(h) => { // Dial-first even when the presence pips say offline — a // routed/VPN host is mDNS-blind and probe-shy but dials fine. + // A pinned card connects with ITS profile (one-off, §5.2a); + // the primary tile keeps the host's default binding. fx.connect = Some(ConnectIntent { addr: h.addr.clone(), port: h.port, fp_hex: h.fp_hex.clone(), launch: None, - title: h.name.clone(), + title: match &h.pin { + Some(p) => format!("{} · {}", h.name, p.name), + None => h.name.clone(), + }, request_access: false, + profile: h.pin.as_ref().map(|p| p.id.clone()), }); } } @@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 let max_w = f64::from(rect.width()) - 2.0 * pad; let sub_base = f64::from(rect.bottom) - pad; - fonts.draw_clipped( - canvas, - &format!("{}:{}", h.addr, h.port), - l, - sub_base, - W::Regular, - 13.0 * k, - white(0.55), - max_w, - ); + match (&h.pin, &h.bound_profile) { + // A pinned card: the profile name IS the subtitle, tinted with its accent — + // the card's whole point is "this host, with these settings" (§5.2a). + (Some(p), _) => { + fonts.draw_clipped( + canvas, + &p.name, + l, + sub_base, + W::SemiBold, + 13.0 * k, + accent_color(p.accent.as_deref()), + max_w, + ); + } + // The primary tile says which profile a plain press uses, after the address. + (None, Some(b)) => { + let addr = format!("{}:{}", h.addr, h.port); + let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k)); + fonts.draw_clipped( + canvas, + &addr, + l, + sub_base, + W::Regular, + 13.0 * k, + white(0.55), + max_w, + ); + let x = l + addr_w + 8.0 * k; + if x < l + max_w { + fonts.draw_clipped( + canvas, + &format!("· {}", b.name), + x, + sub_base, + W::SemiBold, + 13.0 * k, + accent_color(b.accent.as_deref()), + l + max_w - x, + ); + } + } + (None, None) => { + fonts.draw_clipped( + canvas, + &format!("{}:{}", h.addr, h.port), + l, + sub_base, + W::Regular, + 13.0 * k, + white(0.55), + max_w, + ); + } + } fonts.draw_clipped( canvas, &h.name, @@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 ); } +/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed +/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring. +fn accent_color(accent: Option<&str>) -> skia_safe::Color4f { + let Some(hex) = accent + .and_then(|a| a.strip_prefix('#')) + .filter(|h| h.len() == 6) + else { + return BRAND; + }; + let Ok(v) = u32::from_str_radix(hex, 16) else { + return BRAND; + }; + skia_safe::Color4f::new( + ((v >> 16) & 0xff) as f32 / 255.0, + ((v >> 8) & 0xff) as f32 / 255.0, + (v & 0xff) as f32 / 255.0, + 1.0, + ) +} + fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { crate::theme::panel( canvas, @@ -484,6 +556,8 @@ mod tests { can_wake, last_used: None, os: String::new(), + pin: None, + bound_profile: None, } } @@ -551,6 +625,38 @@ mod tests { )); } + /// A pinned card's A-press is a connect WITH its profile (one-off), titled so the + /// connecting takeover says which settings are coming (§5.2a). + #[test] + fn pinned_card_connects_with_its_profile() { + let mut settings = ctx_settings(); + let mut pinned = host("ab\0p1", true, true, false); + pinned.name = "Tower".into(); + pinned.pin = Some(crate::model::ProfileChip { + id: "p1".into(), + name: "Work".into(), + accent: None, + }); + let hosts = [pinned]; + let pads: Vec = Vec::new(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "test", + t: 0.0, + }; + let mut s = HomeScreen::new(); + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + let intent = fx.connect.expect("a pinned card connects"); + assert_eq!(intent.profile.as_deref(), Some("p1")); + assert_eq!(intent.title, "Tower · Work"); + } + #[test] fn add_tile_is_always_last() { let mut settings = ctx_settings(); diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 83facd3a..c7d13722 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -120,6 +120,8 @@ impl LibraryScreen { launch: Some(g.id.clone()), title: g.title.clone(), request_access: false, + // Game launches follow the host's default binding. + profile: None, }); Some(MenuPulse::Confirm) } diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index ef117170..20a87703 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -221,6 +221,7 @@ impl PairScreen { launch: None, title: self.host_name.clone(), request_access: true, + profile: None, }); fx.pop(); } @@ -430,6 +431,8 @@ mod tests { can_wake: false, last_used: None, os: String::new(), + pin: None, + bound_profile: None, } } diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs new file mode 100644 index 00000000..ad7dab4e --- /dev/null +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -0,0 +1,266 @@ +//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card +//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's +//! Profiles section. One toggle row per saved host; a toggle rides +//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles` +//! and refreshes the rows — the row's shown state follows the model, so what the list +//! says is always what the store holds (and what Decky's host list will render). + +use crate::glyphs::{Hint, HintKey}; +use crate::model::ConsoleCmd; +use crate::screens::{Ctx, Outbox}; +use crate::theme::{Fonts, DIM, W}; +use crate::widgets::{ListMsg, MenuList, RowSpec}; +use pf_client_core::gamepad::{MenuEvent, MenuPulse}; +use skia_safe::{Canvas, Rect}; + +pub(crate) struct PinHostsScreen { + profile_id: String, + profile_name: String, + list: MenuList, +} + +/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the +/// OUTPUT of this screen, not a row in it), in the model's carousel order. +fn host_indices(ctx: &Ctx) -> Vec { + ctx.hosts + .iter() + .enumerate() + .filter(|(_, h)| h.saved && h.pin.is_none()) + .map(|(i, _)| i) + .collect() +} + +impl PinHostsScreen { + pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen { + PinHostsScreen { + profile_id, + profile_name, + list: MenuList::new(), + } + } + + pub(crate) fn profile_name(&self) -> &str { + &self.profile_name + } + + /// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from + /// the model — the pinned card's row IS the state, so the toggle can never disagree + /// with what the carousel shows. + fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool { + let host = &ctx.hosts[host_idx]; + ctx.hosts.iter().any(|r| { + r.addr == host.addr + && r.port == host.port + && r.pin.as_ref().is_some_and(|p| p.id == self.profile_id) + }) + } + + pub(crate) fn menu( + &mut self, + ev: MenuEvent, + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { + if ev == MenuEvent::Back { + fx.pop(); + return None; + } + let indices = host_indices(ctx); + let (msg, pulse) = self.list.menu(ev, indices.len()); + let Some(&host_idx) = indices.get(self.list.cursor) else { + return pulse; + }; + // Toggle semantics shared with the settings rows: left = unpin, right = pin, + // A flips; asking for the state it's already in is a boundary thud. + let target = match msg { + ListMsg::Adjust(delta) => delta > 0, + ListMsg::Activate => !self.pinned(ctx, host_idx), + ListMsg::None => return pulse, + }; + if self.pinned(ctx, host_idx) == target { + return Some(MenuPulse::Boundary); + } + fx.cmds.push(ConsoleCmd::SetPin { + key: ctx.hosts[host_idx].key.clone(), + profile_id: self.profile_id.clone(), + pin: target, + }); + Some(MenuPulse::Move) + } + + pub(crate) fn hints(&self, ctx: &Ctx) -> Vec { + if host_indices(ctx).is_empty() { + return vec![Hint::new(HintKey::Back, "Done")]; + } + vec![ + Hint::new(HintKey::Confirm, "Pin / Unpin"), + Hint::new(HintKey::Back, "Done"), + ] + } + + pub(crate) fn render( + &mut self, + canvas: &Canvas, + rect: Rect, + k: f64, + dt: f64, + fonts: &Fonts, + ctx: &mut Ctx, + ) { + let indices = host_indices(ctx); + let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0; + if indices.is_empty() { + fonts.centered( + canvas, + "No saved hosts yet — pair with a host first, then pin this profile to it.", + W::Regular, + 14.0 * k, + DIM, + cx, + f64::from(rect.top) + f64::from(rect.height()) / 2.0, + f64::from(rect.width()) * 0.7, + ); + return; + } + // The explainer band under the list, like the settings screen's detail text. + let detail_h = 34.0 * k; + let list_rect = Rect::from_ltrb( + rect.left, + rect.top, + rect.right, + rect.bottom - detail_h as f32, + ); + let rows: Vec = indices + .iter() + .map(|&i| { + let h = &ctx.hosts[i]; + let pinned = self.pinned(ctx, i); + RowSpec { + header: None, + label: h.name.clone(), + value: Some(if pinned { + "Pinned".into() + } else { + "Off".into() + }), + value_dim: !pinned, + caret: false, + adjustable: true, + enabled: true, + } + }) + .collect(); + self.list + .render(canvas, list_rect, &rows, fonts, k, dt, true); + fonts.centered( + canvas, + "A pinned profile appears as its own card on the host — one press connects with it.", + W::Regular, + 13.0 * k, + DIM, + cx, + f64::from(rect.bottom) - detail_h + 6.0 * k, + f64::from(rect.width()) * 0.8, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{HostRow, ProfileChip}; + use crate::screens::Outbox; + use pf_client_core::trust::Settings; + + fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow { + HostRow { + key: key.into(), + name: key.into(), + addr: "10.0.0.9".into(), + port: 9777, + fp_hex: key.into(), + paired: true, + saved, + online: true, + mgmt_port: 47990, + can_wake: false, + last_used: None, + os: String::new(), + pin: pin.map(|id| ProfileChip { + id: id.into(), + name: "Work".into(), + accent: None, + }), + bound_profile: None, + } + } + + #[test] + fn toggling_sends_set_pin_for_the_focused_host() { + let mut settings = Settings::default(); + let pads = Vec::new(); + let library = crate::library::LibraryShared::default(); + let hosts = [host("aa", true, None), host("bb", true, None)]; + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = PinHostsScreen::new("p1".into(), "Work".into()); + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert_eq!( + fx.cmds, + vec![ConsoleCmd::SetPin { + key: "aa".into(), + profile_id: "p1".into(), + pin: true, + }] + ); + + // Left on an unpinned host = already off = boundary, no command. + let mut fx = Outbox::default(); + let pulse = s.menu( + MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left), + &mut ctx, + &mut fx, + ); + assert!(fx.cmds.is_empty()); + assert!(matches!(pulse, Some(MenuPulse::Boundary))); + } + + #[test] + fn state_reads_from_the_models_pinned_rows() { + let mut settings = Settings::default(); + let pads = Vec::new(); + let library = crate::library::LibraryShared::default(); + // Host "aa" already carries a pinned card for p1; its primary row toggles OFF. + let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))]; + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = PinHostsScreen::new("p1".into(), "Work".into()); + // Only the primary row is a toggle row. + assert_eq!(host_indices(&ctx).len(), 1); + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert_eq!( + fx.cmds, + vec![ConsoleCmd::SetPin { + key: "aa".into(), + profile_id: "p1".into(), + pin: false, + }] + ); + } +} diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 35995361..b7656278 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -6,7 +6,7 @@ //! read the same file, so values round-trip freely. use crate::glyphs::{Hint, HintKey}; -use crate::screens::{Ctx, Outbox}; +use crate::screens::{Ctx, Outbox, Screen}; use crate::theme::{Fonts, DIM, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; @@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect}; /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale /// index when the pad list under the "Use controller" row churns. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RowId { + /// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens + /// the pin-to-hosts screen. The console never edits profiles (design §5.4). + Profile(usize), + /// The Profiles section's placeholder while the catalog is empty. + NoProfiles, Resolution, Refresh, RenderScale, @@ -50,7 +55,8 @@ enum RowId { // 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) and the profile catalog stay desktop-only. +// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the +// trailing Profiles section) but created and edited only in the desktop app (design §5.4). const ROWS: [RowId; 27] = [ RowId::Resolution, RowId::Refresh, @@ -149,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [ pub(crate) struct SettingsScreen { list: MenuList, + /// The profile catalog's `(id, name)` pairs, loaded once at construction — the console + /// can't create profiles (design §5.4: the desktop app does), so the list is stable + /// for the screen's lifetime. + profiles: Vec<(String, String)>, } impl SettingsScreen { pub(crate) fn new() -> SettingsScreen { + Self::with_profiles( + pf_client_core::profiles::ProfilesFile::load() + .profiles + .into_iter() + .map(|p| (p.id, p.name)) + .collect(), + ) + } + + fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen { SettingsScreen { list: MenuList::new(), + profiles, } } + /// The full row list: the fixed settings rows, then the Profiles section — one row + /// per catalog profile, or the explainer placeholder while there are none. + fn row_ids(&self) -> Vec { + let mut ids = ROWS.to_vec(); + if self.profiles.is_empty() { + ids.push(RowId::NoProfiles); + } else { + ids.extend((0..self.profiles.len()).map(RowId::Profile)); + } + ids + } + pub(crate) fn menu( &mut self, ev: MenuEvent, @@ -168,7 +201,31 @@ impl SettingsScreen { fx.pop(); return None; } - let (msg, pulse) = self.list.menu(ev, ROWS.len()); + let ids = self.row_ids(); + let (msg, pulse) = self.list.menu(ev, ids.len()); + // The Profiles rows navigate instead of editing the settings file. + match ids[self.list.cursor] { + RowId::Profile(i) => { + return match msg { + ListMsg::Activate => { + let (id, name) = self.profiles[i].clone(); + fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new( + id, name, + ))); + pulse + } + ListMsg::Adjust(_) => Some(MenuPulse::Boundary), + ListMsg::None => pulse, + } + } + RowId::NoProfiles => { + return match msg { + ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary), + ListMsg::None => pulse, + } + } + _ => {} + } // Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this // screen is one of the settings file's several whole-file writers (profiles.rs // documents the no-merge debt), and adjusting a stale snapshot would silently @@ -180,7 +237,7 @@ impl SettingsScreen { } match msg { ListMsg::Adjust(delta) => { - let changed = adjust(ROWS[self.list.cursor], delta, false, ctx); + let changed = adjust(ids[self.list.cursor], delta, false, ctx); if changed { ctx.settings.save(); Some(MenuPulse::Move) @@ -190,7 +247,7 @@ impl SettingsScreen { } ListMsg::Activate => { // A cycles forward WRAPPING, so every option is reachable one-handed. - if adjust(ROWS[self.list.cursor], 1, true, ctx) { + if adjust(ids[self.list.cursor], 1, true, ctx) { ctx.settings.save(); } pulse @@ -200,11 +257,18 @@ impl SettingsScreen { } pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { - vec![ - Hint::new(HintKey::Adjust, "Adjust"), - Hint::new(HintKey::Confirm, "Change"), - Hint::new(HintKey::Back, "Done"), - ] + match self.row_ids()[self.list.cursor] { + RowId::Profile(_) => vec![ + Hint::new(HintKey::Confirm, "Pin to hosts…"), + Hint::new(HintKey::Back, "Done"), + ], + RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")], + _ => vec![ + Hint::new(HintKey::Adjust, "Adjust"), + Hint::new(HintKey::Confirm, "Change"), + Hint::new(HintKey::Back, "Done"), + ], + } } pub(crate) fn render( @@ -224,10 +288,14 @@ impl SettingsScreen { rect.right, rect.bottom - detail_h as f32, ); - let rows: Vec = ROWS.iter().map(|id| row_spec(*id, ctx)).collect(); + let ids = self.row_ids(); + let rows: Vec = ids + .iter() + .map(|id| row_spec(*id, ctx, &self.profiles)) + .collect(); self.list .render(canvas, list_rect, &rows, fonts, k, dt, true); - let detail = detail(ROWS[self.list.cursor]); + let detail = detail(ids[self.list.cursor]); fonts.centered( canvas, detail, @@ -241,7 +309,38 @@ impl SettingsScreen { } } -fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { +fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { + // The Profiles section: name + how many hosts pin it (counted from the live rows, so + // it reflects what the carousel shows). Read-only here beyond opening the pin screen. + match id { + RowId::Profile(i) => { + let (pid, name) = &profiles[i]; + let pins = ctx + .hosts + .iter() + .filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid)) + .count(); + return RowSpec { + header: (i == 0).then_some("Profiles"), + label: name.clone(), + value: Some(match pins { + 0 => "Not pinned".into(), + 1 => "Pinned to 1 host".into(), + n => format!("Pinned to {n} hosts"), + }), + value_dim: pins == 0, + caret: false, + adjustable: false, + enabled: true, + }; + } + RowId::NoProfiles => { + let mut row = RowSpec::action("No profiles yet", false); + row.header = Some("Profiles"); + return row; + } + _ => {} + } let s = &ctx.settings; // Several rows follow another: echo cancellation only means anything while the mic // streams, the pad rows only while any controller is forwarded at all, and the @@ -382,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { ), 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 { header, @@ -477,6 +577,16 @@ fn detail(id: RowId) -> &'static str { 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 \ + Punktfunk desktop app." + } + RowId::NoProfiles => { + "Profiles bundle stream settings for different uses (a low-latency one, a \ + quality one…). Create them in the Punktfunk desktop app, then pin them \ + here as one-press connect cards." + } } } @@ -611,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { 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, } .is_some() } @@ -736,7 +848,7 @@ mod tests { device_name: "t", t: 0.0, }; - assert!(!row_spec(RowId::EchoCancel, &ctx).enabled); + assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled); assert!( !adjust(RowId::EchoCancel, -1, false, &mut ctx), "mic off = thud" @@ -745,7 +857,7 @@ mod tests { assert!(ctx.settings.echo_cancel, "and nothing was written"); ctx.settings.mic_enabled = true; - assert!(row_spec(RowId::EchoCancel, &ctx).enabled); + assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled); assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx)); assert!(!ctx.settings.echo_cancel); assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx)); @@ -771,7 +883,7 @@ mod tests { device_name: "t", t: 0.0, }; - assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled); assert!( !adjust(RowId::SmoothBuffer, 1, false, &mut ctx), "latency intent = thud" @@ -781,14 +893,14 @@ mod tests { // Stepping the intent to Smoothness brings the buffer row to life. assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx)); assert_eq!(ctx.settings.present_priority, "smooth"); - assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled); assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx)); assert_eq!(ctx.settings.smooth_buffer, 1); // The intent wraps back and the row goes inert again. assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx)); assert_eq!(ctx.settings.present_priority, "latency"); - assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled); } #[test] @@ -864,4 +976,110 @@ mod tests { assert!(adjust(RowId::Bitrate, 1, false, &mut ctx)); assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic"); } + + /// The Profiles section trails the settings rows: one row per catalog profile whose + /// value counts the pinned cards in the live model, activating opens the pin screen, + /// and left/right (which edits every other row) is a boundary — a profile row + /// navigates, it must never fall into the settings save path. + #[test] + fn profile_rows_navigate_instead_of_editing() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut pinned = crate::model::HostRow { + key: "aa\0p1".into(), + name: "Tower".into(), + addr: "10.0.0.9".into(), + port: 9777, + fp_hex: "aa".into(), + paired: true, + saved: true, + online: true, + mgmt_port: 47990, + can_wake: false, + last_used: None, + os: String::new(), + pin: Some(crate::model::ProfileChip { + id: "p1".into(), + name: "Work".into(), + accent: None, + }), + bound_profile: None, + }; + let hosts = [pinned.clone(), { + pinned.key = "aa".into(); + pinned.pin = None; + pinned + }]; + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = SettingsScreen::with_profiles(vec![ + ("p1".into(), "Work".into()), + ("p2".into(), "Game".into()), + ]); + let ids = s.row_ids(); + assert_eq!(ids.len(), ROWS.len() + 2); + assert_eq!(ids[ROWS.len()], RowId::Profile(0)); + + let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles); + assert_eq!(spec.header, Some("Profiles")); + assert_eq!(spec.label, "Work"); + assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host")); + let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles); + assert_eq!(spec.header, None, "only the first row carries the header"); + assert_eq!(spec.value.as_deref(), Some("Not pinned")); + + s.list.cursor = ROWS.len(); // onto "Work" + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert!( + matches!(fx.nav, Some(crate::screens::Nav::Push(b)) + if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")), + "A on a profile row opens its pin screen" + ); + + let mut fx = Outbox::default(); + let pulse = s.menu( + MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right), + &mut ctx, + &mut fx, + ); + assert!(matches!(pulse, Some(MenuPulse::Boundary))); + assert!(fx.nav.is_none() && fx.cmds.is_empty()); + } + + /// An empty catalog shows the explainer placeholder — present, inert, and dimmed — + /// so the section still tells the user where profiles come from. + #[test] + fn empty_catalog_shows_the_placeholder() { + let (mut settings, pads) = ctx_parts(); + 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, + }; + let mut s = SettingsScreen::with_profiles(Vec::new()); + let ids = s.row_ids(); + assert_eq!(*ids.last().unwrap(), RowId::NoProfiles); + let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles); + assert_eq!(spec.header, Some("Profiles")); + assert!(!spec.enabled); + + s.list.cursor = ids.len() - 1; + let mut fx = Outbox::default(); + let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert!(matches!(pulse, Some(MenuPulse::Boundary))); + assert!(fx.nav.is_none()); + } } diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index e49d0a47..6994109f 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -239,8 +239,14 @@ impl Shell { port: h.port, fp_hex: h.fp_hex.clone(), launch: None, - title: h.name.clone(), + // A wake started from a pinned card carries its profile + // through to the connect (the row's key found it again). + title: match &h.pin { + Some(p) => format!("{} · {}", h.name, p.name), + None => h.name.clone(), + }, request_access: false, + profile: h.pin.as_ref().map(|p| p.id.clone()), }) }); self.bus.send(ConsoleCmd::CancelWake); @@ -269,6 +275,7 @@ impl Shell { launch: intent.launch, title: intent.title, request_access: intent.request_access, + profile: intent.profile, }); } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 65a157ba..d49e1a44 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -32,6 +32,8 @@ fn hosts() -> Vec { can_wake: false, last_used: None, os: String::new(), + pin: None, + bound_profile: None, }; vec![ HostRow { diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index 1afa6021..ea2c48cc 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -88,8 +88,9 @@ pub enum ConsoleEntry { /// The host list (bare `--browse`). Home, /// Home with this host's library already pushed (`--browse host` — the Decky - /// per-host launch; B backs out to Home). - Library(HostRow), + /// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless + /// `Home` variant when it learned its profile chips. + Library(Box), } /// The binary's ends of the console: models to write, commands to serve. diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index 472d4112..9bca9afa 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -84,6 +84,11 @@ pub enum OverlayAction { fp_hex: String, launch: Option, title: String, + /// One-off settings-profile override for THIS launch (a profile id — a pinned + /// card's connect). `None` resolves the host's default binding as before; the + /// binary feeds it to `trust::effective_settings`, so a dangling id quietly + /// falls back to the defaults and never blocks the connect. + profile: Option, /// The no-PIN delegated-approval path: pin the host's advertised fingerprint and /// open a connect the host PARKS until the operator approves this device in its /// console (a long connect budget), then persist it as paired. `false` = an From 80b4eccff943e56d3bde820ea40e22404d4dddba Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:41:03 +0200 Subject: [PATCH 2/4] feat(apple/gamepad): Profiles section + pin picker in gamepad settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GamepadSettingsView gains a trailing Profiles section (one row per catalog profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker driving HostStore.setPinned — the first pin management reachable from the controller-first UI, and on tvOS the only possible one. tvOS wording drops the 'create them in the standard interface' promise (no profile editor exists there); other platforms keep it. Pinned-card rendering and the connect path were already in from WP5 and stay untouched. --- .../Home/GamepadHomeView.swift | 4 +- .../Screenshots/ScreenshotScenes.swift | 4 +- .../Settings/GamepadSettingsView.swift | 169 ++++++++++++++++-- 3 files changed, 164 insertions(+), 13 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index e94b356f..0bd48f7c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -135,7 +135,7 @@ struct GamepadHomeView: View { // fullScreenCover, so they become generously sized sheets over the dimmed launcher. #if os(macOS) .sheet(isPresented: $showSettings) { - GamepadSettingsView() + GamepadSettingsView(store: store) .frame(width: 720, height: 640) } .sheet(isPresented: $showAddHost) { @@ -144,7 +144,7 @@ struct GamepadHomeView: View { } .frame(minWidth: 640, minHeight: 420) #else - .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() } + .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) } .fullScreenCover(isPresented: $showAddHost) { GamepadAddHostView { store.add($0) } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 9133a0f9..b5c27331 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -146,7 +146,9 @@ private struct ShotGamepadHome: View { } private struct ShotGamepadSettings: View { - var body: some View { GamepadSettingsView() } + @StateObject private var store = ShotMock.hostStore() + + var body: some View { GamepadSettingsView(store: store) } } private struct ShotGamepadAddHost: View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 49ba392b..8c218dd9 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -10,6 +10,14 @@ // on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells // the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable // with one button. Toggles read left = off, right = on — refusing a no-op with the same thud. +// +// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager +// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an +// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with +// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned. +// Pins are presentation only: never the host's default binding, never the profile itself — +// profiles are created and edited in the standard interface (and can't be on tvOS, whose +// per-device catalog the detail strings are honest about). import PunktfunkKit import SwiftUI @@ -21,6 +29,10 @@ import CoreHaptics struct GamepadSettingsView: View { @Environment(\.dismiss) private var dismiss + /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows + /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen + /// itself (ContentView owns the instance). + @ObservedObject var store: HostStore @AppStorage(DefaultsKey.streamWidth) private var width = 1920 @AppStorage(DefaultsKey.streamHeight) private var height = 1080 @AppStorage(DefaultsKey.streamHz) private var hz = 60 @@ -52,6 +64,10 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false #endif @ObservedObject private var gamepads = GamepadManager.shared + /// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the + /// Profiles rows re-derive from it each render, so a rename/delete made in the standard + /// interface shows up live. + @ObservedObject private var profiles = ProfileStore.shared #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so more rows fit. @@ -62,6 +78,9 @@ struct GamepadSettingsView: View { private let compact = false // no size classes on macOS; the sheet is sized generously #endif @State private var focusID: String? + /// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per + /// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows. + @State private var pinTarget: StreamProfile? /// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the /// changed value slides in from, so the animation follows the user's motion. @State private var lastAdjustDelta = 1 @@ -72,7 +91,7 @@ struct GamepadSettingsView: View { focusID: $focusID, onAdjust: { row, delta in adjust(id: row.id, by: delta) }, onActivate: { activate(id: $0.id) }, - onBack: { dismiss() } + onBack: { back() } ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -80,7 +99,7 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - Text("Settings") + Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(.white) .padding(.top, gamepadTitleTopPadding(compact: compact)) @@ -96,11 +115,7 @@ struct GamepadSettingsView: View { .foregroundStyle(.white.opacity(0.55)) .lineLimit(2, reservesSpace: true) .animation(.smooth(duration: 0.2), value: focusID) - GamepadHintBar(hints: [ - .init(glyph: "arrow.left.and.right", text: "Adjust"), - .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), - .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), - ]) + GamepadHintBar(hints: hints) } // Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView). .padding(.leading, compact ? 12 : 18) @@ -138,6 +153,43 @@ struct GamepadSettingsView: View { .accessibilityLabel("Close settings") } + /// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which + /// layer the row list currently is. + private var title: String { + pinTarget.map { "Pin “\($0.name)”" } ?? "Settings" + } + + /// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the + /// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one + /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. + private var hints: [GamepadHint] { + guard pinTarget != nil else { + return [ + .init(glyph: "arrow.left.and.right", text: "Adjust"), + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), + ] + } + guard !store.hosts.isEmpty else { + return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")] + } + return [ + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"), + ] + } + + /// B peels one layer: the pin picker back to the settings rows — focus returning to the + /// profile row it came from — then the screen itself. + private func back() { + if let profile = pinTarget { + pinTarget = nil + focusID = "profile-\(profile.id)" + } else { + dismiss() + } + } + // MARK: - Row rendering private func rowView(_ row: Row, focused: Bool) -> some View { @@ -164,7 +216,7 @@ struct GamepadSettingsView: View { HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) + .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -185,7 +237,7 @@ struct GamepadSettingsView: View { .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) + .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) } } .padding(.horizontal, m.rowHPad) @@ -219,6 +271,9 @@ struct GamepadSettingsView: View { let value: String /// One-line explanation shown near the hint bar while this row is focused. let detail: String + /// Whether left/right means anything here — false hides the value's chevrons (the + /// Profiles rows navigate, and the placeholder rows do nothing at all). + var adjustable = true /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). let adjust: (Int) -> Bool /// A — cycle forward (wrapping) / flip. @@ -238,6 +293,9 @@ struct GamepadSettingsView: View { } private var rows: [Row] { + // The pin picker replaces the whole list while it's up — same screen, one layer deeper, + // so the focus list's controller wiring (and the tvOS focus engine) carries over as is. + if let profile = pinTarget { return pinRows(for: profile) } let resolution = resolutionOptions let refresh = SettingsOptions.refreshRates(including: hz) .map { (label: "\($0) Hz", tag: $0) } @@ -394,7 +452,98 @@ struct GamepadSettingsView: View { at: at + 1) } #endif - return list + return list + profileRows + } + + // MARK: - Profiles (§5.2a) + + /// The trailing Profiles section: one row per catalog profile, its value how many saved + /// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface + /// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so + /// left/right is a boundary thud, not an editor. + private var profileRows: [Row] { + guard !profiles.profiles.isEmpty else { + return [Row( + id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3", + label: "No profiles yet", value: "", + detail: emptyCatalogDetail, + adjustable: false, + adjust: { _ in false }, activate: {})] + } + return profiles.profiles.enumerated().map { i, profile in + let pins = store.hosts + .filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count + return Row( + id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil, + icon: "slider.horizontal.3", label: profile.name, + value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")", + detail: profileDetail, + adjustable: false, + adjust: { _ in false }, + activate: { + // Focus lands on the picker's first row — the focus list's reconcile + // follows this id when the row set swaps underneath it. + focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts" + pinTarget = profile + }) + } + } + + /// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows' + /// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a + /// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and + /// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a). + private func pinRows(for profile: StreamProfile) -> [Row] { + guard !store.hosts.isEmpty else { + return [Row( + id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet", + value: "", + detail: "Pair with a host first, then pin this profile to it.", + adjustable: false, + adjust: { _ in false }, activate: {})] + } + return store.hosts.map { host in + let hostID = host.id + let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id) + return Row( + id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer", + label: host.displayName, + value: pinned ? "Pinned" : "Off", + detail: "A pinned profile appears as its own card on the host — one press " + + "connects with it.", + adjust: { delta in + let target = delta > 0 + guard pinned != target else { return false } + store.setPinned(hostID, profileID: profile.id, pinned: target) + return true + }, + activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) }) + } + } + + /// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group + /// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV + /// user at a "standard interface" would promise profiles that can never arrive there. + private var profileDetail: String { + #if os(tvOS) + return "Pin this profile to a host and it appears as its own card on the home screen — " + + "one press connects with it." + #else + return "Pin this profile to a host and it appears as its own card — one press connects " + + "with it. Profiles are created and edited in Punktfunk's standard interface." + #endif + } + + /// What the empty catalog's placeholder explains — again honest on tvOS, where profiles + /// cannot be created (on the device or anywhere that would reach its per-device catalog). + private var emptyCatalogDetail: String { + #if os(tvOS) + return "Profiles bundle stream settings for different uses. Creating them isn't " + + "available on Apple TV yet." + #else + return "Profiles bundle stream settings for different uses. Create them in Punktfunk's " + + "standard interface, then pin them here as one-press connect cards." + #endif } /// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode From 857d7d7b6b5da389b8a0f968f256d65996acffa9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:41:04 +0200 Subject: [PATCH 3/4] feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GamepadSettingsScreen gains the trailing Profiles section (per-profile rows with live pin counts, touch-interface explainer) and a console-styled GamepadPinHostsDialog — controller- and TV-remote-navigable pin management writing KnownHost.pinnedProfileIds through the existing store path. Pin-add was previously touch-only; pinned-card rendering and unpin stay as they were. --- .../io/unom/punktfunk/GamepadDialogs.kt | 135 +++++++++++++++++ .../unom/punktfunk/GamepadSettingsScreen.kt | 137 ++++++++++++++++-- 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 2aaf2bfe..a3f27ec2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.security.ClientIdentity +import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.models.PendingTrust import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog( } } +/** + * The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the + * desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down + * moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes. + * A toggle is presentation only: it edits the host's pinned cards through the same store write the + * carousel's unpin uses, never the profile itself and never the host's default binding. + * + * Pin state is read live from [pinned] (backed by the host records), so what a switch shows is + * always what the store holds — the row can't disagree with the carousel it feeds. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GamepadPinHostsDialog( + profileName: String, + hosts: List, + pinned: (KnownHost) -> Boolean, + onToggle: (KnownHost) -> Unit, + onDismiss: () -> Unit, +) { + // 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS + // Done, so it starts focused). + var focus by remember { mutableIntStateOf(0) } + BackHandler(onBack = onDismiss) + GamepadNavEffect2D( + active = true, + onDirection = { dir -> + when (dir) { + NavDir.UP -> if (focus > 0) focus-- + NavDir.DOWN -> if (focus < hosts.size) focus++ + // Directional = state-targeted (left → unpinned, right → pinned), so holding a + // direction can't oscillate; asking for the state it's already in is a no-op. + NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) } + NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) } + } + }, + onActivate = { + val kh = hosts.getOrNull(focus) + if (kh != null) onToggle(kh) else onDismiss() + }, + ) + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(24.dp) + .widthIn(max = 520.dp) + .heightIn(max = maxCardHeight) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "Pin “$profileName”", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Column( + Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (hosts.isEmpty()) { + DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.") + } else { + DialogText("A pinned profile appears as its own card on the host — one press connects with it.") + hosts.forEachIndexed { i, kh -> + PinHostRow( + label = kh.name, + on = pinned(kh), + focused = i == focus, + onClick = { onToggle(kh) }, + ) + } + } + Spacer(Modifier.size(4.dp)) + DialogButton( + "Done", + focused = focus == hosts.size, + primary = true, + enabled = true, + onClick = onDismiss, + ) + } + } + } +} + +/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) { + val visuals = animateConsoleFocus(active = focused) + // Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short + // landscape window pulls itself into view. + val intoView = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } + val shape = RoundedCornerShape(14.dp) + Row( + Modifier + .fillMaxWidth() + .bringIntoViewRequester(intoView) + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + .clip(shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.weight(1f)) + ConsoleSwitch(on = on, focused = focused) + } +} + /** * Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a * powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 42ec3017..a0309f3c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.kit.deviceBodyVibrator +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.KnownHostStore // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with @@ -72,6 +74,8 @@ private class GpRow( val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed val activate: () -> Unit, // A → cycle forward (wrapping) / flip val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) + val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons + val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) ) @Composable @@ -89,7 +93,35 @@ fun GamepadSettingsScreen( val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } // Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`). val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null } - val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + // The Profiles section's stores, constructed here the way ConnectScreen constructs its own. + // The catalog is read once per screen entry: this screen can't create or edit profiles + // (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved + // hosts DO change under it — every pin toggle writes one — so they live in state and refresh + // on each toggle, keeping the "Pinned to N hosts" counts honest. + val knownHostStore = remember { KnownHostStore(context) } + val profileStore = remember { ProfileStore(context) } + val profiles = remember { profileStore.all() } + var savedHosts by remember { mutableStateOf(knownHostStore.all()) } + // The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad + // (this screen's nav gates on it, the ConnectScreen-dialog pattern). + var pinProfile by remember { mutableStateOf(null) } + + // Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation + // only: pin appends at the end (card order), unpin removes, and the host's default binding + // (profileId) is never touched. + fun togglePin(kh: KnownHost, profile: StreamProfile) { + val pins = if (profile.id in kh.pinnedProfileIds) { + kh.pinnedProfileIds - profile.id + } else { + kh.pinnedProfileIds + profile.id + } + knownHostStore.save(kh.copy(pinnedProfileIds = pins)) + savedHosts = knownHostStore.all() + } + + val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + buildProfileRows(profiles, savedHosts) { pinProfile = it } var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the @@ -101,7 +133,9 @@ fun GamepadSettingsScreen( BackHandler(onBack = onBack) GamepadNavEffect2D( - active = navActive, + // The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen + // drops its probes — the pattern ConnectScreen's dialogs use. + active = navActive && pinProfile == null, onDirection = { dir -> when (dir) { NavDir.UP -> if (focus > 0) focus-- @@ -162,16 +196,41 @@ fun GamepadSettingsScreen( .then(if (landscape) Modifier else Modifier.systemBarsPadding()) .padding(ConsoleLegendInset), ) { + // The legend follows the focused row (the desktop console's hints() does the same): + // a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet" + // placeholder does nothing at all — advertising ↔/A on those would be a lie. + val focused = rows.getOrNull(focus) GamepadHintBar( - listOf( - GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), - // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. - PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, - PadGlyph.hint('B', "Done", onClick = onBack), - ), + when { + focused != null && !focused.enabled -> listOf( + PadGlyph.hint('B', "Done", onClick = onBack), + ) + focused != null && !focused.adjustable -> listOf( + PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + else -> listOf( + GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), + // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. + PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + }, hazeState = hazeState, ) } + + // The pin-to-hosts picker for the activated profile row — the console counterpart of the + // touch UI's per-profile pin toggles in the host edit sheet. + pinProfile?.let { p -> + GamepadPinHostsDialog( + profileName = p.name, + hosts = savedHosts, + pinned = { kh -> p.id in kh.pinnedProfileIds }, + onToggle = { kh -> togglePin(kh, p) }, + onDismiss = { pinProfile = null }, + ) + } } } @@ -180,8 +239,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when - // focus arrives; the value colour cross-fades with them. - val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons") + // focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row + // navigates, the empty-catalog placeholder does nothing) never shows them at all. + val chevronAlpha by animateFloatAsState( + if (focused && row.adjustable) 0.6f else 0f, + tween(160), + label = "chevrons", + ) val valueColor by animateColorAsState( Color.White.copy(alpha = if (focused) 1f else 0.6f), tween(160), @@ -216,7 +280,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick row.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = Color.White, + // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, + // so its detail line can still explain what would go here. + color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f), maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -435,3 +501,52 @@ private fun buildSettingsRows( ) { update(s.copy(sc2Capture = it)) }, ) } + +/** + * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): + * one row per catalog profile, valued with how many saved hosts pin it, activating into the + * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the touch + * interface, so an empty catalog shows one dimmed placeholder explaining where they come from + * instead of a dead-looking empty header. + */ +private fun buildProfileRows( + profiles: List, + savedHosts: List, + openPinPicker: (StreamProfile) -> Unit, +): List { + if (profiles.isEmpty()) { + return listOf( + GpRow( + id = "noProfiles", + header = "Profiles", + label = "No profiles yet", + value = "", + detail = "Profiles bundle stream settings for different uses. Create them in the " + + "touch interface, then pin them here as one-press connect cards.", + adjust = { false }, + activate = {}, + adjustable = false, + enabled = false, + ), + ) + } + return profiles.mapIndexed { i, p -> + // Counted straight off the host records, so it agrees with what the carousel renders. + val pins = savedHosts.count { p.id in it.pinnedProfileIds } + GpRow( + id = "profile:${p.id}", + header = if (i == 0) "Profiles" else null, + label = p.name, + value = when (pins) { + 0 -> "Not pinned" + 1 -> "Pinned to 1 host" + else -> "Pinned to $pins hosts" + }, + detail = "Pin this profile to a host and it appears as its own card — one press " + + "connects with it. Profiles are created and edited in the touch interface.", + adjust = { false }, + activate = { openPinPicker(p) }, + adjustable = false, + ) + } +} From ff5602361f36b5d08a56ddef959818239be5d2b7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:47:20 +0200 Subject: [PATCH 4/4] fix(android/gamepad): TV wording points at the Controller-optimized UI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Created and edited in the touch interface' is dead advice on a TV box — no touch to reach it with. Unlike tvOS the editor DOES exist on-device (same APK), behind this screen's own Controller-optimized UI toggle, so on TV the Profiles strings now name that route instead. --- .../unom/punktfunk/GamepadSettingsScreen.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index a0309f3c..6a808516 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -120,8 +120,12 @@ fun GamepadSettingsScreen( savedHosts = knownHostStore.all() } + // On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest + // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard + // interface remote-navigably. The strings branch on it. + val tv = remember { isTvDevice(context) } val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + - buildProfileRows(profiles, savedHosts) { pinProfile = it } + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the @@ -505,15 +509,25 @@ private fun buildSettingsRows( /** * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): * one row per catalog profile, valued with how many saved hosts pin it, activating into the - * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the touch + * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard * interface, so an empty catalog shows one dimmed placeholder explaining where they come from - * instead of a dead-looking empty header. + * instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points + * nowhere useful on a touchless device, so the strings name the actual route — the + * Controller-optimized UI toggle a few rows up, which swaps the standard interface in + * (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists). */ private fun buildProfileRows( profiles: List, savedHosts: List, + tv: Boolean, openPinPicker: (StreamProfile) -> Unit, ): List { + val createHint = if (tv) { + "To create or edit profiles on this device, turn off Controller-optimized UI above " + + "and use the standard interface." + } else { + "Profiles are created and edited in the touch interface." + } if (profiles.isEmpty()) { return listOf( GpRow( @@ -521,8 +535,8 @@ private fun buildProfileRows( header = "Profiles", label = "No profiles yet", value = "", - detail = "Profiles bundle stream settings for different uses. Create them in the " + - "touch interface, then pin them here as one-press connect cards.", + detail = "Profiles bundle stream settings for different uses — pinned ones become " + + "one-press connect cards here. " + createHint, adjust = { false }, activate = {}, adjustable = false, @@ -543,7 +557,7 @@ private fun buildProfileRows( else -> "Pinned to $pins hosts" }, detail = "Pin this profile to a host and it appears as its own card — one press " + - "connects with it. Profiles are created and edited in the touch interface.", + "connects with it. " + createHint, adjust = { false }, activate = { openPinPicker(p) }, adjustable = false,