diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index 09ae9a9c..ab744cfa 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -209,7 +209,7 @@ impl Screen { pub(crate) fn title(&self, _ctx: &Ctx) -> String { match self { Screen::Home(_) => "Select a Host".into(), - Screen::Library(s) => s.host_name().to_string(), + Screen::Library(s) => s.title(), Screen::Settings(_) => "Settings".into(), Screen::AddHost(s) => s.title(), Screen::Pair(s) => format!("Pair with {}", s.host_name()), diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index a85978f1..91f005b0 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -10,7 +10,7 @@ use crate::library::{ StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W, RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE, }; -use crate::model::{ConsoleCmd, HostRow}; +use crate::model::{ConsoleCmd, HostRow, ProfileChip}; use crate::pointer::{Pointer, PointerKind}; use crate::screens::{ConnectIntent, Ctx, Outbox}; use crate::theme::{accent, fg, Fonts, W}; @@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen { port: u16, fp_hex: String, mgmt: u16, + /// `Some` when this library was opened from a PINNED host+profile card (§5.2a) rather + /// than the host's primary tile: every launch off this shelf is that card's connect + /// with a title attached, so it carries the same one-off profile the card's plain + /// A-press would. `None` = the primary tile, where the host's binding decides. + pin: Option, shared: Option, // Synced snapshot of the shared model (re-pulled when the generation bumps). generation: u64, @@ -48,6 +53,7 @@ impl LibraryScreen { port: host.port, fp_hex: host.fp_hex.clone(), mgmt: host.mgmt_port, + pin: host.pin.clone(), shared: None, // adopted from Ctx on the first render (the shell owns it) generation: u64::MAX, phase: LibraryPhase::Loading, @@ -60,8 +66,13 @@ impl LibraryScreen { } } - pub(crate) fn host_name(&self) -> &str { - &self.host_name + /// The screen's title: the host, and — when this shelf belongs to a pinned card — the + /// profile every launch off it will use, in the card's own `host · profile` shape. + pub(crate) fn title(&self) -> String { + match &self.pin { + Some(p) => format!("{} \u{b7} {}", self.host_name, p.name), + None => self.host_name.clone(), + } } fn fetch_cmd(&self) -> ConsoleCmd { @@ -123,10 +134,18 @@ impl LibraryScreen { port: self.port, fp_hex: self.fp_hex.clone(), launch: Some(g.id.clone()), - title: g.title.clone(), + // A pinned card's shelf says which profile it is launching with, + // the same way its tile and this screen's title do. + title: match &self.pin { + Some(p) => format!("{} \u{b7} {}", g.title, p.name), + None => g.title.clone(), + }, request_access: false, - // Game launches follow the host's default binding. - profile: None, + // A game launch off a PINNED card's shelf is that card's connect + // with a title attached — it carries the card's profile as the + // one-off. Off the primary tile there is none, and the host's + // default binding decides. + profile: self.pin.as_ref().map(|p| p.id.clone()), }); Some(MenuPulse::Confirm) } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 5d97774c..0e8898f9 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -180,6 +180,87 @@ fn finish_motion(s: &mut Shell) { s.motion = Motion::None; } +/// A pinned host+profile card's library launches with THAT profile (design §5.2a). +/// +/// The card's plain A-press always carried its profile; Y — which the card offers, being +/// paired and saved — opened a library screen that knew only the host, so every title +/// launched off it silently fell back to the host's default binding. The profile a user +/// pinned is the whole reason they pressed that card. +#[test] +fn a_pinned_cards_library_launches_with_its_profile() { + let mut rows = hosts(); + let card = HostRow { + key: "aa11\u{0}hdr".into(), + pin: Some(crate::model::ProfileChip { + id: "hdr".into(), + name: "HDR".into(), + accent: None, + }), + ..rows[0].clone() + }; + rows.insert(1, card); + let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]); + console.set_hosts(rows); + s.sync(); + + // Focus the pinned card (it sits right after its host's primary tile), then Y. + s.handle_menu(MenuEvent::Move(MenuDir::Right)); + s.handle_menu(MenuEvent::Secondary); + finish_motion(&mut s); + match s.stack.last() { + Some(Screen::Library(l)) => assert_eq!( + l.title(), + "Living Room PC \u{b7} HDR", + "the shelf names the profile it will launch with" + ), + _ => panic!("Y on a pinned card opens its library"), + } + + library.set_games(vec![crate::library::LibraryGame { + id: "steam:570".into(), + title: "Dota 2".into(), + store: "steam".into(), + launcher: false, + icon: String::new(), + }]); + s.handle_menu(MenuEvent::Confirm); + match s.take_action() { + Some(OverlayAction::Launch { + launch, profile, .. + }) => { + assert_eq!(launch.as_deref(), Some("steam:570")); + assert_eq!( + profile.as_deref(), + Some("hdr"), + "the launch carries the pinned card's profile" + ); + } + _ => panic!("A on a title raises a launch"), + } +} + +/// …and off the host's PRIMARY tile there is no one-off: the host's binding decides, +/// which is what the resolver sees as `None`. +#[test] +fn a_primary_tiles_library_leaves_the_profile_to_the_binding() { + let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.sync(); + s.handle_menu(MenuEvent::Secondary); // paired+online host focused first + finish_motion(&mut s); + library.set_games(vec![crate::library::LibraryGame { + id: "steam:570".into(), + title: "Dota 2".into(), + store: "steam".into(), + launcher: false, + icon: String::new(), + }]); + s.handle_menu(MenuEvent::Confirm); + assert!(matches!( + s.take_action(), + Some(OverlayAction::Launch { profile: None, .. }) + )); +} + #[test] fn wake_gates_input_in_the_same_press() { let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);