diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 7bd78a70..24b7298e 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -266,6 +266,11 @@ pub fn run(target: Option<&str>) -> u8 { ActionOutcome::Start(Box::new(params)) } OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side + // Also run-loop-side: the clipboard belongs to SDL, which this callback + // has no handle on. Unreachable in practice — listed so adding an action + // to the enum keeps failing loudly here instead of falling into a + // wildcard that silently drops it. + OverlayAction::CopyText(_) => ActionOutcome::Handled, OverlayAction::Quit => ActionOutcome::Quit, } }); @@ -286,6 +291,21 @@ pub fn run(target: Option<&str>) -> u8 { } } +/// A console row key → its index in the known-hosts store. The key is the pinned +/// fingerprint when there is one, else `addr:port` (see the row builder), and a pinned +/// CARD's key carries the profile id past a NUL — the console strips that before it +/// sends a command, so nothing here has to. +fn index_for_key(known: &trust::KnownHosts, key: &str) -> Option { + 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()?) + }) +} + fn host_display_name(name: &str, addr: &str) -> String { if name.trim().is_empty() { addr.to_string() @@ -483,6 +503,48 @@ impl ServiceState { } self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now } + ConsoleCmd::UpdateHost { + key, + name, + addr, + port, + } => { + let mut known = trust::KnownHosts::load(); + let Some(h) = index_for_key(&known, &key).and_then(|i| known.hosts.get_mut(i)) + else { + tracing::warn!(%key, "edit for an unknown host — ignoring"); + return; + }; + // Edited IN PLACE rather than removed and re-added: the fingerprint, the + // learned MAC, the pinned cards and the profile binding all hang off this + // entry, and re-adding would silently unpair a host the user only renamed. + h.name = if name.trim().is_empty() { + addr.clone() + } else { + name + }; + h.addr = addr; + h.port = port; + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving known hosts"); + } + self.last_probe = Instant::now() - Duration::from_secs(60); // the address moved + } + ConsoleCmd::ForgetHost { key } => { + let mut known = trust::KnownHosts::load(); + let Some(i) = index_for_key(&known, &key) else { + tracing::warn!(%key, "forget for an unknown host — ignoring"); + return; + }; + let gone = known.hosts.remove(i); + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving known hosts"); + } + tracing::info!(name = %gone.name, addr = %gone.addr, "host forgotten"); + // It may still be advertising, in which case it comes straight back as a + // DISCOVERED row — unsaved and unpaired, which is the honest state. + self.last_probe = Instant::now() - Duration::from_secs(60); + } ConsoleCmd::Wake { key, then_connect } => { if let Some(c) = self.wake_cancel.take() { c.store(true, Ordering::SeqCst); @@ -534,14 +596,7 @@ impl ServiceState { // 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 idx = index_for_key(&known, &key); let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else { tracing::warn!(%key, "pin toggle for an unknown host — ignoring"); return; diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs index fa224355..89db0a83 100644 --- a/crates/pf-console-ui/src/glyphs.rs +++ b/crates/pf-console-ui/src/glyphs.rs @@ -42,6 +42,8 @@ pub(crate) enum HintKey { Shoulders, /// ◀ ▶ — left/right adjusts the focused value. Adjust, + /// ▲ — up opens the focused item's own menu. + Up, Key(&'static str), } @@ -62,7 +64,17 @@ impl Hint { const LABEL_SIZE: f64 = 14.0; const BADGE_D: f64 = 22.0; // face-button badge diameter -/// The hint bar pill, anchored at its BOTTOM-LEFT corner. Returns the pill's size. +/// What a drawn hint bar left behind. +pub(crate) struct HintBar { + /// The pill's `(width, height)`. + pub size: (f64, f64), + /// One hit box per hint, in the order they were given. The legend is also the console's + /// only on-screen list of what the face buttons do, so for a pointer — which has no + /// face buttons — it doubles as the button bar itself. + pub rects: Vec<(HintKey, Rect)>, +} + +/// The hint bar pill, anchored at its BOTTOM-LEFT corner. pub(crate) fn hint_bar( canvas: &Canvas, fonts: &Fonts, @@ -71,9 +83,12 @@ pub(crate) fn hint_bar( x: f64, bottom: f64, k: f64, -) -> (f64, f64) { +) -> HintBar { if hints.is_empty() { - return (0.0, 0.0); + return HintBar { + size: (0.0, 0.0), + rects: Vec::new(), + }; } let pad = 13.0 * k; let gap_hint = 18.0 * k; @@ -111,7 +126,19 @@ pub(crate) fn hint_bar( let cy = bottom - h / 2.0; let mut pen = x + pad; + let mut rects = Vec::with_capacity(hints.len()); for (hint, (gw, lw)) in hints.iter().zip(&widths) { + // Glyph + label + half the gap to the next hint, full pill height: a comfortable + // target without stealing the neighbour's. + rects.push(( + hint.key, + Rect::from_xywh( + (pen - gap_glyph / 2.0) as f32, + (bottom - h) as f32, + (gw + gap_glyph + lw + gap_hint / 2.0) as f32, + h as f32, + ), + )); draw_glyph(canvas, fonts, hint.key, style, pen, cy, k); pen += gw + gap_glyph; // Baseline centered on the badge (cap height ≈ 0.72 em for Geist). @@ -126,13 +153,17 @@ pub(crate) fn hint_bar( ); pen += lw + gap_hint; } - (w, h) + HintBar { + size: (w, h), + rects, + } } fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 { match resolved(key, style) { Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k, Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k, + Resolved::Up => BADGE_D * k, Resolved::Key(text) => keycap_w(fonts, text, k), } } @@ -151,6 +182,9 @@ enum Resolved { Badge(Face), Shoulders, Adjust, + /// The d-pad's up — drawn the same in every style, because it is a direction rather + /// than a button whose label changes with the pad. + Up, Key(&'static str), } @@ -169,8 +203,11 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved { HintKey::Back => Resolved::Key("Esc"), HintKey::Secondary => Resolved::Key("Y"), HintKey::Tertiary => Resolved::Key("X"), - HintKey::Shoulders => Resolved::Key("PgUp/PgDn"), + // Tab is the key a keyboard reaches for to change section; PgUp/PgDn still + // work, but naming both here makes the legend wider than the hint is worth. + HintKey::Shoulders => Resolved::Key("Tab"), HintKey::Adjust => Resolved::Adjust, + HintKey::Up => Resolved::Up, HintKey::Key(t) => Resolved::Key(t), }; } @@ -181,6 +218,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved { HintKey::Secondary => Resolved::Badge(Face::Y), HintKey::Shoulders => Resolved::Shoulders, HintKey::Adjust => Resolved::Adjust, + HintKey::Up => Resolved::Up, HintKey::Key(t) => Resolved::Key(t), } } @@ -251,6 +289,18 @@ fn draw_glyph( pen += w + 3.0 * k; } } + Resolved::Up => { + // ▲ — one solid triangle in a badge-sized slot. + let r = BADGE_D * k / 2.0; + let (cx, cyf) = ((x + r) as f32, cy as f32); + let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32); + let mut up = Path::new(); + up.move_to((cx, cyf - th)); + up.line_to((cx - tw, cyf + th)); + up.line_to((cx + tw, cyf + th)); + up.close(); + canvas.draw_path(&up, &Paint::new(fg(0.85), None)); + } Resolved::Adjust => { // ◀ ▶ — two small solid triangles. let r = BADGE_D * k / 2.0; diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index 933ae725..94852f10 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -22,6 +22,8 @@ pub mod library; #[cfg(any(target_os = "linux", windows))] pub mod model; #[cfg(any(target_os = "linux", windows))] +mod pointer; +#[cfg(any(target_os = "linux", windows))] mod screens; #[cfg(any(target_os = "linux", windows))] mod shell; diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index ac5b0102..d1d756cd 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -156,6 +156,18 @@ pub enum ConsoleCmd { addr: String, port: u16, }, + /// Rename / re-address a saved host (the host menu's "Edit…"). `key` addresses the + /// row; the fingerprint, pins and MACs already stored against it are kept — this edits + /// a host, it doesn't replace one. + UpdateHost { + key: String, + name: String, + addr: String, + port: u16, + }, + /// Drop a saved host (the host menu's "Forget"). The next connect to that address + /// starts from scratch: no pin, no pairing, no pinned cards. + ForgetHost { key: String }, /// Start the wake-and-wait loop for this saved host. Wake { key: String, then_connect: bool }, /// Stop the wake loop (B on the wake card) and clear its status. diff --git a/crates/pf-console-ui/src/pointer.rs b/crates/pf-console-ui/src/pointer.rs new file mode 100644 index 00000000..c20c5053 --- /dev/null +++ b/crates/pf-console-ui/src/pointer.rs @@ -0,0 +1,100 @@ +//! Pointer and touch input inside the console. +//! +//! The console is a focus UI: a pad moves a cursor and presses A. A pointer brings its +//! own cursor, so every widget resolves a press directly onto whatever is under it and +//! **acts on the press**, not on the release. +//! +//! That is deliberate, not a shortcut. Both the menu list and the two carousels scroll +//! the FOCUSED item toward the centre of the screen, so the thing you pressed has already +//! slid out from under your finger by the time it lifts. A click-on-release rule would +//! have to chase it, and on a touchscreen — where the finger doesn't move but the content +//! does — it would routinely land on the wrong row. Press-to-act has no such race, and +//! the console has no drag gesture for it to compete with. +//! +//! Coordinates are device pixels: the run loop converts (it owns the window and therefore +//! the display scale), and a widget hit-tests the very rect it drew last frame. + +use skia_safe::Rect; + +/// A pointer/touch interaction, in device pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Pointer { + pub x: f64, + pub y: f64, + pub kind: PointerKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PointerKind { + /// The primary button went down, or a finger touched the glass — the acting edge. + Press, + /// The primary button or finger came up. Widgets ignore it today; it is carried so a + /// later drag gesture has an edge to close on. + Release, + /// Motion, with or without a button held. + Move, + /// The gesture was abandoned (the pointer left the window). + Cancel, + /// One scroll step; `up` = away from the user. + Scroll { up: bool }, + /// The secondary (right) button went down — the pointer's B. Handled by the shell for + /// every screen at once, so no screen has to remember to offer a way back. + Back, +} + +impl Pointer { + /// Is this the edge widgets act on? + pub(crate) fn press(&self) -> bool { + self.kind == PointerKind::Press + } + + /// Inside `rect`? Half-open, so neighbouring rects can share an edge without both + /// claiming the same pixel. An EMPTY rect never hits — which is what lets a list + /// record `Rect::new_empty()` for rows it culled and keep its indices aligned. + pub(crate) fn hits(&self, rect: Rect) -> bool { + let (x, y) = (self.x as f32, self.y as f32); + x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom + } + + /// The index of the first rect under the pointer. + pub(crate) fn pick(&self, rects: &[Rect]) -> Option { + rects.iter().position(|r| self.hits(*r)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(x: f64, y: f64) -> Pointer { + Pointer { + x, + y, + kind: PointerKind::Press, + } + } + + #[test] + fn hit_testing_is_half_open_and_skips_empty_rects() { + let r = Rect::from_xywh(10.0, 10.0, 20.0, 20.0); + assert!(at(10.0, 10.0).hits(r), "the top-left corner is inside"); + assert!( + !at(30.0, 20.0).hits(r), + "the right edge belongs to the next" + ); + assert!(!at(9.0, 20.0).hits(r)); + // A culled row's placeholder must never swallow a press. + assert!(!at(0.0, 0.0).hits(Rect::new_empty())); + } + + #[test] + fn pick_returns_the_first_match() { + let rects = [ + Rect::new_empty(), + Rect::from_xywh(0.0, 0.0, 10.0, 10.0), + Rect::from_xywh(0.0, 0.0, 10.0, 10.0), + ]; + assert_eq!(at(5.0, 5.0).pick(&rects), Some(1)); + assert_eq!(at(50.0, 5.0).pick(&rects), None); + } +} diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index 9a839ffe..09ae9a9c 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -5,6 +5,7 @@ pub(crate) mod add_host; pub(crate) mod home; +pub(crate) mod host_options; pub(crate) mod library; pub(crate) mod pair; pub(crate) mod pin_hosts; @@ -13,6 +14,7 @@ pub(crate) mod settings; use crate::glyphs::Hint; use crate::library::LibraryShared; use crate::model::{ConsoleCmd, HostRow}; +use crate::pointer::Pointer; use crate::theme::Fonts; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::{gamepad::PadInfo, trust}; @@ -68,6 +70,10 @@ pub(crate) enum Nav { Push(Box), /// Pop this screen; popping the root quits the console. Pop, + /// Swap this screen for another, animated as a push. What "Edit\u{2026}" needs: the host + /// menu has said its piece, and leaving it on the stack would make Back from the editor + /// land on a menu describing the host as it was BEFORE the edit. + Replace(Box), } /// Everything a screen's input handling may ask of the shell, collected per event and @@ -78,6 +84,9 @@ pub(crate) struct Outbox { pub connect: Option, pub cmds: Vec, pub toast: Option, + /// Text for the system clipboard. Rides out to the run loop rather than the command + /// bus because the clipboard belongs to SDL, which the service thread never touches. + pub copy: Option, } impl Outbox { @@ -88,6 +97,30 @@ impl Outbox { pub(crate) fn pop(&mut self) { self.nav = Some(Nav::Pop); } + + pub(crate) fn replace(&mut self, screen: Screen) { + self.nav = Some(Nav::Replace(Box::new(screen))); + } +} + +/// This row's `punktfunk://` link, built from the STORE so it carries the fingerprint and +/// stable id a row doesn't hold — the same builder the desktop shells' "Copy link" uses, +/// so a link is identical whichever surface hands it to you. `None` if the host has left +/// the store since the menu was opened. +pub(crate) fn host_link(row: &HostRow) -> Option { + let known = trust::KnownHosts::load(); + let host = (!row.fp_hex.is_empty()) + .then(|| known.find_by_fp(&row.fp_hex)) + .flatten() + .or_else(|| known.find_by_addr(&row.addr, row.port))?; + Some( + pf_client_core::deeplink::DeepLink::for_host( + host, + None, + row.pin.as_ref().map(|p| p.id.as_str()), + ) + .to_url(), + ) } pub(crate) enum Screen { @@ -97,6 +130,9 @@ 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), } impl Screen { @@ -113,6 +149,24 @@ impl Screen { Screen::AddHost(s) => s.menu(ev, ctx, fx), Screen::Pair(s) => s.menu(ev, ctx, fx), Screen::PinHosts(s) => s.menu(ev, ctx, fx), + Screen::HostOptions(s) => s.menu(ev, ctx, fx), + } + } + + /// Mouse/touch at a point, in device pixels. `true` = consumed. + /// + /// A screen answers `true` for anything landing on its own furniture even when the + /// press does nothing, so a stray tap can't fall through to a layer underneath; `false` + /// only for the empty backdrop. + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + match self { + Screen::Home(s) => s.pointer(p, ctx, fx), + Screen::Library(s) => s.pointer(p, ctx, fx), + Screen::Settings(s) => s.pointer(p, ctx, fx), + Screen::AddHost(s) => s.pointer(p, ctx, fx), + Screen::Pair(s) => s.pointer(p, ctx, fx), + Screen::PinHosts(s) => s.pointer(p, ctx, fx), + Screen::HostOptions(s) => s.pointer(p, ctx, fx), } } @@ -157,9 +211,10 @@ impl Screen { Screen::Home(_) => "Select a Host".into(), Screen::Library(s) => s.host_name().to_string(), Screen::Settings(_) => "Settings".into(), - Screen::AddHost(_) => "Add Host".into(), + Screen::AddHost(s) => s.title(), Screen::Pair(s) => format!("Pair with {}", s.host_name()), Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()), + Screen::HostOptions(s) => s.title(), } } @@ -171,6 +226,7 @@ impl Screen { Screen::AddHost(s) => s.hints(ctx), Screen::Pair(s) => s.hints(ctx), Screen::PinHosts(s) => s.hints(ctx), + Screen::HostOptions(s) => s.hints(ctx), } } @@ -193,6 +249,7 @@ impl Screen { 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), + Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx), } } } diff --git a/crates/pf-console-ui/src/screens/add_host.rs b/crates/pf-console-ui/src/screens/add_host.rs index 71f815a2..dd43e9c6 100644 --- a/crates/pf-console-ui/src/screens/add_host.rs +++ b/crates/pf-console-ui/src/screens/add_host.rs @@ -5,7 +5,8 @@ //! hardware keyboards type straight into the focused field through SDL text input. use crate::glyphs::{Hint, HintKey}; -use crate::model::ConsoleCmd; +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}; @@ -28,6 +29,10 @@ pub(crate) struct AddHostScreen { address: String, port: String, editing: Option, + /// `Some(host key)` = editing a saved host rather than adding one. The same three + /// fields either way — what changes is the verb, and that the write must UPDATE the + /// stored host instead of appending a second one beside it. + edits: Option, } impl AddHostScreen { @@ -39,9 +44,71 @@ impl AddHostScreen { address: String::new(), port: "9777".into(), editing: None, + edits: None, } } + /// The same screen, prefilled, saving over a host instead of adding one. + pub(crate) fn edit(host: &HostRow) -> AddHostScreen { + AddHostScreen { + name: host.name.clone(), + address: host.addr.clone(), + port: host.port.to_string(), + // A pinned card's key carries its profile past a NUL; the HOST is what's edited. + edits: Some(host.key.split('\0').next().unwrap_or(&host.key).to_string()), + ..AddHostScreen::new() + } + } + + pub(crate) fn title(&self) -> String { + if self.edits.is_some() { + "Edit Host".into() + } else { + "Add Host".into() + } + } + + fn commit_label(&self) -> &'static str { + if self.edits.is_some() { + "Save changes" + } else { + "Add host" + } + } + + /// Mouse/touch. A raised keyboard is modal: it takes anything landing on it, and a + /// press outside closes it rather than reaching the row underneath — which is what a + /// tap outside a keyboard means everywhere else on a touchscreen. + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + if self.editing.is_some() && !ctx.deck { + if !self.keyboard.covers(p) { + if p.press() { + self.editing = None; + return true; + } + return false; + } + let (msg, _) = self.keyboard.pointer(p); + match msg { + KeyMsg::Type(c) => { + self.type_char(c); + } + KeyMsg::Backspace => { + self.backspace(); + } + KeyMsg::Done => self.editing = None, + KeyMsg::None => {} + } + return true; + } + let (msg, pulse) = self.list.pointer(p, FIELDS.len() + 1); + if matches!(msg, ListMsg::None) && pulse.is_none() { + return false; + } + self.activate(msg, fx); + true + } + pub(crate) fn editing(&self) -> bool { self.editing.is_some() } @@ -157,27 +224,58 @@ impl AddHostScreen { let (msg, pulse) = self.list.menu(ev, FIELDS.len() + 1); match msg { ListMsg::Activate => { - if self.list.cursor < FIELDS.len() { - self.editing = Some(FIELDS[self.list.cursor]); - } else if self.can_add() { - fx.cmds.push(ConsoleCmd::SaveHost { - name: self.name.trim().to_string(), - addr: self.address.trim().to_string(), - port: self.port.parse().unwrap_or(9777), - }); - fx.toast = Some(format!("Added {}", self.address.trim())); - fx.pop(); - } else { - // Not addable yet — jump to what's missing instead of a dead press. - self.list.cursor = 1; // the address row - self.editing = Some(Field::Address); - } + self.activate(msg, fx); pulse } _ => pulse, } } + /// The commit row's behaviour, shared by the pad/keyboard path and the pointer's. + fn activate(&mut self, msg: ListMsg, fx: &mut Outbox) { + if !matches!(msg, ListMsg::Activate) { + return; + } + if self.list.cursor < FIELDS.len() { + self.editing = Some(FIELDS[self.list.cursor]); + return; + } + if !self.can_add() { + // Not commitable yet — jump to what's missing instead of a dead press. + self.list.cursor = 1; // the address row + self.editing = Some(Field::Address); + return; + } + let (name, addr) = ( + self.name.trim().to_string(), + self.address.trim().to_string(), + ); + let port = self.port.parse().unwrap_or(9777); + match &self.edits { + Some(key) => { + // Name it by its nickname if it has one, else by the address — the same + // fallback the store applies to an unnamed host. + let label = if name.is_empty() { + addr.clone() + } else { + name.clone() + }; + fx.cmds.push(ConsoleCmd::UpdateHost { + key: key.clone(), + name, + addr, + port, + }); + fx.toast = Some(format!("Saved {label}")); + } + None => { + fx.toast = Some(format!("Added {addr}")); + fx.cmds.push(ConsoleCmd::SaveHost { name, addr, port }); + } + } + fx.pop(); + } + pub(crate) fn hints(&self, ctx: &Ctx) -> Vec { if self.editing.is_some() { if ctx.deck { @@ -270,7 +368,7 @@ impl AddHostScreen { ), field_row("Address", &self.address, "IP or hostname", Field::Address), field_row("Port", &self.port, "9777", Field::Port), - RowSpec::action("Add Host", self.can_add()), + RowSpec::action(self.commit_label(), self.can_add()), ] } } diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 7165af33..0a36ec1a 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -9,6 +9,7 @@ use crate::anim::Spring; use crate::glyphs::{Hint, HintKey}; use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K}; use crate::model::{ConsoleCmd, HostRow}; +use crate::pointer::{Pointer, PointerKind}; use crate::screens::{ConnectIntent, Ctx, Outbox, Screen}; use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; @@ -29,6 +30,10 @@ pub(crate) struct HomeScreen { bump: Spring, /// Last-seen tile keys — hosts churn under discovery; focus follows the KEY. keys: Vec, + /// Each tile's rect as last drawn, device px, `Rect::new_empty()` for the ones the + /// carousel culled. Scaled to match: side tiles draw at 0.88, and a press near their + /// edge would otherwise pick a neighbour. + geom: Vec, } impl HomeScreen { @@ -38,6 +43,7 @@ impl HomeScreen { anim: Spring::rest(0.0), bump: Spring::rest(0.0), keys: Vec::new(), + geom: Vec::new(), } } @@ -136,10 +142,55 @@ impl HomeScreen { fx.pop(); // popping the root = quit (the shell's rule) None } + // Up on a saved tile opens that host's own menu — Wake / Copy link / Edit / + // Forget. The carousel is horizontal, so up is the one free direction, and it + // is the gesture the Android console already uses for the same menu. + MenuEvent::Move(MenuDir::Up) => match self.focused(ctx.hosts) { + Some(h) if super::host_options::HostOptionsScreen::available(h) => { + fx.push(Screen::HostOptions( + super::host_options::HostOptionsScreen::new(h), + )); + Some(MenuPulse::Confirm) + } + _ => Some(MenuPulse::Boundary), + }, MenuEvent::Move(_) => None, } } + /// Mouse/touch on the carousel. Pressing the CENTRE tile activates it; pressing any + /// other one only brings it to the centre. + /// + /// The asymmetry is the point: the carousel answers a press by sliding, so a rule that + /// also activated would connect to whichever host you merely aimed at — and on this + /// screen activating means starting a session. Bringing it front first is both the + /// safer read and the one a coverflow trains you to expect. + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + self.reconcile(ctx.hosts); + let len = ctx.hosts.len() + 1; + match p.kind { + PointerKind::Scroll { up } => { + self.step(if up { -1 } else { 1 }, len, false); + true + } + // `i < len` because the geometry is a frame old: discovery can shorten the + // carousel between the render that recorded it and this press, and a cursor + // parked past the end would read as the trailing Add Host tile. + PointerKind::Press => match p.pick(&self.geom).filter(|i| *i < len) { + Some(i) if i == self.cursor as usize => { + self.menu(MenuEvent::Confirm, ctx, fx); + true + } + Some(i) => { + self.cursor = i as i32; + true + } + None => false, + }, + _ => false, + } + } + fn step(&mut self, delta: i32, len: usize, clamp: bool) -> Option { match step_cursor(self.cursor, len, delta, clamp) { StepResult::Moved(to) => { @@ -169,6 +220,12 @@ impl HomeScreen { if self.focused(ctx.hosts).is_some_and(|h| h.paired && h.saved) { hints.push(Hint::new(HintKey::Secondary, "Library")); } + if self + .focused(ctx.hosts) + .is_some_and(super::host_options::HostOptionsScreen::available) + { + hints.push(Hint::new(HintKey::Up, "Options")); + } hints.push(Hint::new(HintKey::Tertiary, "Settings")); hints.push(Hint::new(HintKey::Back, "Quit")); hints @@ -200,6 +257,8 @@ impl HomeScreen { let cy = f64::from(rect.top) + f64::from(rect.height()) / 2.0; let len = ctx.hosts.len() + 1; + self.geom.clear(); + self.geom.resize(len, Rect::new_empty()); for i in 0..len { let d = i as f64 - self.anim.pos; if d.abs() > 2.6 { @@ -215,6 +274,12 @@ impl HomeScreen { tile_w as f32, tile_h as f32, ); + self.geom[i] = Rect::from_xywh( + (cx - tile_w * scale / 2.0) as f32, + (cy - tile_h * scale / 2.0) as f32, + (tile_w * scale) as f32, + (tile_h * scale) as f32, + ); canvas.save(); canvas.translate((cx as f32, cy as f32)); canvas.scale((scale as f32, scale as f32)); diff --git a/crates/pf-console-ui/src/screens/host_options.rs b/crates/pf-console-ui/src/screens/host_options.rs new file mode 100644 index 00000000..f8c5d30d --- /dev/null +++ b/crates/pf-console-ui/src/screens/host_options.rs @@ -0,0 +1,370 @@ +//! 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. +//! +//! 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. +//! +//! 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. + +use crate::glyphs::{Hint, HintKey}; +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 pf_client_core::gamepad::{MenuEvent, MenuPulse}; +use skia_safe::{Canvas, Rect}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Action { + Wake, + CopyLink, + Edit, + Forget, + Unpin, + 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, + 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 + /// a thumbstick from across a room, which is a good reason to be stricter than they + /// are, and none at all to be looser. + armed: bool, +} + +impl HostOptionsScreen { + pub(crate) fn new(host: &HostRow) -> HostOptionsScreen { + HostOptionsScreen { + host: host.clone(), + 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. + pub(crate) fn available(host: &HostRow) -> bool { + host.saved + } + + 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(), + } + } + + /// 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()) + } + + fn actions(&self) -> Vec { + if self.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 { + a.push(Action::Wake); + } + a.extend([ + Action::CopyLink, + Action::Edit, + Action::Forget, + Action::Cancel, + ]); + a + } + + fn label(&self, a: Action) -> String { + match a { + Action::Wake => "Wake host".into(), + Action::CopyLink => "Copy link".into(), + Action::Edit => "Edit\u{2026}".into(), + Action::Forget if self.armed => "Forget \u{2014} press again".into(), + Action::Forget => "Forget".into(), + Action::Unpin => "Unpin card".into(), + Action::Cancel => "Cancel".into(), + } + } + + pub(crate) fn menu( + &mut self, + ev: MenuEvent, + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { + if ev == MenuEvent::Back { + fx.pop(); + return None; + } + let actions = self.actions(); + let (msg, pulse) = self.list.menu(ev, actions.len()); + self.dispatch(msg, pulse, &actions, ctx, fx) + } + + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + let actions = self.actions(); + let (msg, pulse) = self.list.pointer(p, actions.len()); + if matches!(msg, ListMsg::None) && pulse.is_none() { + return false; + } + self.dispatch(msg, pulse, &actions, ctx, fx); + true + } + + fn dispatch( + &mut self, + msg: ListMsg, + pulse: Option, + actions: &[Action], + _ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { + let Some(action) = actions.get(self.list.cursor).copied() else { + return pulse; + }; + // Moving off the armed Forget row disarms it: an arming press is about THAT row, + // and leaving it must not leave a live trigger behind for the next visit. + if !matches!(msg, ListMsg::Activate) && action != Action::Forget { + self.armed = false; + } + match msg { + ListMsg::Adjust(_) => Some(MenuPulse::Boundary), + ListMsg::None => pulse, + ListMsg::Activate => { + self.run(action, fx); + pulse + } + } + } + + fn run(&mut self, action: Action, fx: &mut Outbox) { + let key = self.host_key().to_string(); + match action { + Action::Wake => { + fx.cmds.push(ConsoleCmd::Wake { + key, + then_connect: false, + }); + fx.pop(); + } + Action::CopyLink => { + match crate::screens::host_link(&self.host) { + Some(url) => { + fx.copy = Some(url); + fx.toast = Some("Link copied".into()); + } + // Only if the host left the store between opening this menu and now. + None => fx.toast = Some("This host isn't saved any more".into()), + } + fx.pop(); + } + Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit( + &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.pop(); + } + Action::Unpin => { + if let Some(p) = &self.host.pin { + fx.cmds.push(ConsoleCmd::SetPin { + key, + profile_id: p.id.clone(), + pin: false, + }); + fx.toast = Some(format!("Unpinned {}", p.name)); + } + fx.pop(); + } + Action::Cancel => fx.pop(), + } + } + + pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { + vec![ + Hint::new(HintKey::Confirm, "Choose"), + Hint::new(HintKey::Back, "Close"), + ] + } + + pub(crate) fn render( + &mut self, + canvas: &Canvas, + rect: Rect, + k: f64, + dt: f64, + fonts: &Fonts, + _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( + canvas, + blurb, + W::Regular, + 13.0 * k, + fg(0.55), + cx, + f64::from(rect.top) + 2.0 * k, + f64::from(rect.width()) * 0.72, + ); + let list_rect = Rect::from_ltrb( + rect.left, + rect.top + (34.0 * k) as f32, + rect.right, + rect.bottom, + ); + let rows: Vec = self + .actions() + .into_iter() + .map(|a| RowSpec::action(self.label(a), true)) + .collect(); + self.list + .render(canvas, list_rect, &rows, fonts, k, dt, true); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ProfileChip; + + fn host() -> HostRow { + HostRow { + key: "aa".into(), + name: "Desk".into(), + addr: "10.0.0.5".into(), + port: 9777, + fp_hex: "aa".into(), + paired: true, + saved: true, + online: true, + mgmt_port: 9778, + can_wake: false, + last_used: None, + os: String::new(), + pin: None, + bound_profile: None, + } + } + + fn pinned() -> HostRow { + HostRow { + key: "aa\u{0}prof-1".into(), + pin: Some(ProfileChip { + id: "prof-1".into(), + name: "4K".into(), + accent: None, + }), + ..host() + } + } + + #[test] + fn a_discovered_host_has_no_menu() { + assert!(HostOptionsScreen::available(&host())); + assert!(!HostOptionsScreen::available(&HostRow { + saved: false, + ..host() + })); + } + + #[test] + fn wake_is_offered_only_when_it_would_do_something() { + let awake = HostOptionsScreen::new(&HostRow { + can_wake: true, + online: true, + ..host() + }); + assert!(!awake.actions().contains(&Action::Wake)); + let asleep = HostOptionsScreen::new(&HostRow { + can_wake: true, + online: false, + ..host() + }); + assert!(asleep.actions().contains(&Action::Wake)); + } + + #[test] + fn a_pinned_card_cannot_forget_or_edit_the_host() { + let s = HostOptionsScreen::new(&pinned()); + assert_eq!( + s.actions(), + vec![Action::Unpin, Action::CopyLink, Action::Cancel] + ); + // …and its commands still address the HOST, not the pin's composite key. + assert_eq!(s.host_key(), "aa"); + } + + #[test] + fn forget_needs_two_presses() { + let mut s = HostOptionsScreen::new(&host()); + let actions = s.actions(); + let i = actions.iter().position(|a| *a == Action::Forget).unwrap(); + s.list.cursor = i; + let mut fx = Outbox::default(); + + s.run(Action::Forget, &mut fx); + assert!(fx.cmds.is_empty(), "the first press only arms"); + assert!(s.armed); + assert!(s.label(Action::Forget).contains("press again")); + + s.run(Action::Forget, &mut fx); + assert_eq!( + fx.cmds, + vec![ConsoleCmd::ForgetHost { key: "aa".into() }], + "the second press forgets" + ); + } + + #[test] + fn leaving_the_forget_row_disarms_it() { + let mut s = HostOptionsScreen::new(&host()); + let actions = s.actions(); + s.armed = true; + s.list.cursor = actions.iter().position(|a| *a == Action::Cancel).unwrap(); + let mut ctx_settings = pf_client_core::trust::Settings::default(); + let mut ctx = Ctx { + hosts: &[], + library: &crate::library::LibraryShared::default(), + settings: &mut ctx_settings, + pads: &[], + deck: false, + device_name: "test", + t: 0.0, + }; + let mut fx = Outbox::default(); + s.dispatch(ListMsg::None, None, &actions, &mut ctx, &mut fx); + assert!(!s.armed, "a cursor move off the row cancels the arming"); + } +} diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 3f62a2a3..53ba78ec 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -11,6 +11,7 @@ use crate::library::{ RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE, }; use crate::model::{ConsoleCmd, HostRow}; +use crate::pointer::{Pointer, PointerKind}; use crate::screens::{ConnectIntent, Ctx, Outbox}; use crate::theme::{accent, fg, Fonts, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; @@ -30,6 +31,9 @@ pub(crate) struct LibraryScreen { games: Vec, // Navigation: the integer cursor is the authority; the eased position chases it. cursor: i32, + /// Each card's rect as last drawn (axis-aligned, scale applied — the perspective tilt + /// is a few degrees and well inside a finger's slop), empty for culled cards. + geom: Vec, anim: Spring, bump: Spring, /// Decoded posters by game id (decode once; Skia uploads lazily on first draw). @@ -49,6 +53,7 @@ impl LibraryScreen { phase: LibraryPhase::Loading, games: Vec::new(), cursor: 0, + geom: Vec::new(), anim: Spring::rest(0.0), bump: Spring::rest(0.0), art: HashMap::new(), @@ -152,6 +157,43 @@ impl LibraryScreen { } } + /// Mouse/touch on the coverflow. Same rule as the home carousel: the centre card + /// launches, any other one only comes to the front. + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + match p.kind { + PointerKind::Scroll { up } => { + self.step(if up { -1 } else { 1 }, false); + true + } + PointerKind::Press => { + // The cards OVERLAP, and the ones nearest the cursor are drawn on top — + // so among the rects a press falls in, the topmost is the nearest. Picking + // the first by index would hand the press to a card buried underneath. + let hit = self + .geom + .iter() + .enumerate() + // The geometry is a frame old; a library refresh can shorten the shelf + // between the render that recorded it and this press. + .filter(|(i, r)| *i < self.games.len() && p.hits(**r)) + .min_by_key(|(i, _)| (*i as i32 - self.cursor).abs()) + .map(|(i, _)| i); + match hit { + Some(i) if i == self.cursor as usize => { + self.menu(MenuEvent::Confirm, ctx, fx); + true + } + Some(i) => { + self.cursor = i as i32; + true + } + None => false, + } + } + _ => false, + } + } + fn step(&mut self, delta: i32, clamp: bool) -> Option { match step_cursor(self.cursor, self.games.len(), delta, clamp) { StepResult::Moved(to) => { @@ -326,6 +368,8 @@ impl LibraryScreen { // dense side stacks overlap toward the focus. let mut order: Vec = (0..self.games.len()).collect(); order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs())); + self.geom.clear(); + self.geom.resize(self.games.len(), Rect::new_empty()); for i in order { let d = i as f64 - pos; @@ -342,6 +386,12 @@ impl LibraryScreen { d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k }; let ccx = f64::from(rect.left) + w / 2.0 + offset + bump; + self.geom[i] = Rect::from_xywh( + (ccx - card_w * scale / 2.0) as f32, + (cy - card_h * scale / 2.0) as f32, + (card_w * scale) as f32, + (card_h * scale) as f32, + ); let m = card_matrix(ccx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k); let game = &self.games[i]; diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index 45244d69..833bf9ae 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -6,6 +6,7 @@ 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}; @@ -206,6 +207,52 @@ impl PairScreen { } let roles = self.roles(); let (msg, pulse) = self.list.menu(ev, roles.len()); + self.activate(msg, pulse, &roles, ctx, fx) + } + + /// Mouse/touch. The raised keyboard is modal, exactly as on the add-host screen: it + /// takes what lands on it, and a press outside closes it rather than reaching through. + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + if self.editing.is_some() && !ctx.deck { + if !self.keyboard.covers(p) { + if p.press() { + self.editing = None; + return true; + } + return false; + } + let (msg, _) = self.keyboard.pointer(p); + match msg { + KeyMsg::Type(c) => { + self.type_char(c); + } + KeyMsg::Backspace => { + if let Some(f) = self.editing { + self.field_mut(f).pop(); + } + } + KeyMsg::Done => self.editing = None, + KeyMsg::None => {} + } + return true; + } + let roles = self.roles(); + let (msg, pulse) = self.list.pointer(p, roles.len()); + if matches!(msg, ListMsg::None) && pulse.is_none() { + return false; + } + self.activate(msg, pulse, &roles, ctx, fx); + true + } + + fn activate( + &mut self, + msg: ListMsg, + pulse: Option, + roles: &[Role], + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { match msg { ListMsg::Activate => { match roles.get(self.list.cursor) { diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs index 924b643b..5342151b 100644 --- a/crates/pf-console-ui/src/screens/pin_hosts.rs +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -7,6 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; +use crate::pointer::Pointer; use crate::screens::{Ctx, Outbox}; use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; @@ -67,6 +68,28 @@ impl PinHostsScreen { } let indices = host_indices(ctx); let (msg, pulse) = self.list.menu(ev, indices.len()); + self.toggle(msg, pulse, &indices, ctx, fx) + } + + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + let indices = host_indices(ctx); + let (msg, pulse) = self.list.pointer(p, indices.len()); + if matches!(msg, ListMsg::None) && pulse.is_none() { + return false; + } + self.toggle(msg, pulse, &indices, ctx, fx); + true + } + + /// One list message against the focused host's pin — shared by both input paths. + fn toggle( + &mut self, + msg: ListMsg, + pulse: Option, + indices: &[usize], + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { let Some(&host_idx) = indices.get(self.list.cursor) else { return pulse; }; diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 2eddccfd..08cf48b6 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -8,8 +8,12 @@ //! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with //! inline headers, which on a Deck meant thumbing past Video and Audio to reach the pad //! settings; a tab is one shoulder press, and each tab remembers where its cursor was. +//! A tab is also one Tab keypress, and one click or tap on its pill — the strip shipped +//! reachable by shoulder buttons alone, which left it unusable to everyone holding a +//! mouse or touching the glass. use crate::glyphs::{Hint, HintKey}; +use crate::pointer::Pointer; use crate::screens::{Ctx, Outbox, Screen}; use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H}; @@ -267,12 +271,26 @@ impl SettingsScreen { } } - /// L1/R1 — move one tab, wrapping (the strip is a ring, like A's value cycle), keeping - /// each tab's own cursor. + #[cfg(test)] + pub(crate) fn tab_for_test(&self) -> usize { + self.tab + } + + /// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's + /// value cycle), keeping each tab's own cursor. fn switch_tab(&mut self, delta: i32) -> Option { - self.tab_cursors[self.tab] = self.list.cursor; let n = TABS.len() as i32; - self.tab = (self.tab as i32 + delta).rem_euclid(n) as usize; + self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize) + } + + /// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in: + /// a press on a pill names a tab outright rather than a direction to step in. + fn show_tab(&mut self, tab: usize) -> Option { + if tab >= TABS.len() { + return None; + } + self.tab_cursors[self.tab] = self.list.cursor; + self.tab = tab; // Clamp the remembered cursor: the Profiles tab's length follows the catalog. let len = self.row_ids().len(); self.list @@ -280,6 +298,22 @@ impl SettingsScreen { Some(MenuPulse::Move) } + /// Mouse/touch. The strip is checked first — its pills sit above the list and a press + /// there is never meant for a row. + pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool { + if let Some(tab) = self.strip.pointer(p) { + self.show_tab(tab); + return true; + } + let ids = self.row_ids(); + let (msg, pulse) = self.list.pointer(p, ids.len()); + if matches!(msg, ListMsg::None) && pulse.is_none() { + return false; + } + self.apply_row(msg, pulse, &ids, ctx, fx); + true + } + pub(crate) fn menu( &mut self, ev: MenuEvent, @@ -297,6 +331,19 @@ impl SettingsScreen { } let ids = self.row_ids(); let (msg, pulse) = self.list.menu(ev, ids.len()); + self.apply_row(msg, pulse, &ids, ctx, fx) + } + + /// What a list message means on the focused row — shared by the pad/keyboard path and + /// the pointer's, so a click and an A press can never drift apart. + fn apply_row( + &mut self, + msg: ListMsg, + pulse: Option, + ids: &[RowId], + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { // The Profiles rows navigate instead of editing the settings file. match ids[self.list.cursor] { RowId::Profile(i) => { @@ -932,6 +979,163 @@ mod tests { (Settings::default(), Vec::new()) } + /// 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() { + use std::sync::OnceLock; + static HOME: OnceLock = OnceLock::new(); + let dir = HOME.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("pf-settings-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + }); + std::env::set_var("HOME", dir); + } + + /// Render the screen once so its strip and list carry real geometry, then hand back a + /// pointer aimed at the centre of `rect`. Hit-testing reads what was DRAWN, so a test + /// that skipped the render would be testing nothing. + fn rendered(screen: &mut SettingsScreen) -> f64 { + let fonts = crate::theme::build_fonts().unwrap(); + let (w, h) = (1280i32, 800i32); + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).unwrap(); + 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 k = f64::from(h) / 800.0; + screen.render( + surface.canvas(), + Rect::from_ltrb(0.0, 64.0, w as f32, h as f32 - 86.0), + k, + 1.0 / 60.0, + &fonts, + &mut ctx, + ); + k + } + + fn press(r: Rect) -> Pointer { + Pointer { + x: f64::from(r.center_x()), + y: f64::from(r.center_y()), + kind: crate::pointer::PointerKind::Press, + } + } + + fn with_ctx(f: impl FnOnce(&mut Ctx)) { + 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, + }; + f(&mut ctx); + } + + /// The bug this all started from: the tabs answered the shoulder buttons and nothing + /// else, so a mouse or a touchscreen could not change section at all. + #[test] + fn a_press_on_a_pill_selects_that_tab() { + let mut s = SettingsScreen::with_profiles(Vec::new()); + rendered(&mut s); + assert_eq!(s.tab, 0); + for target in [3, 1, TABS.len() - 1, 0] { + let pill = s.strip.pill(target).expect("the strip drew every pill"); + with_ctx(|ctx| { + let mut fx = Outbox::default(); + assert!(s.pointer(press(pill), ctx, &mut fx), "the pill took it"); + }); + assert_eq!(s.tab, target, "pressing pill {target} selects it"); + // Selecting a tab re-lays the strip; re-render so the next pick is current. + rendered(&mut s); + } + } + + /// …and each tab still keeps its own cursor when a POINTER is what switched it. + #[test] + fn a_pressed_tab_restores_that_tabs_cursor() { + let mut s = SettingsScreen::with_profiles(Vec::new()); + rendered(&mut s); + s.list.cursor = 2; + let second = s.strip.pill(1).unwrap(); + with_ctx(|ctx| { + let mut fx = Outbox::default(); + s.pointer(press(second), ctx, &mut fx); + }); + assert_eq!(s.list.cursor, 0, "a fresh tab starts at its own top"); + rendered(&mut s); + let first = s.strip.pill(0).unwrap(); + with_ctx(|ctx| { + let mut fx = Outbox::default(); + s.pointer(press(first), ctx, &mut fx); + }); + assert_eq!(s.list.cursor, 2, "coming back lands where it was left"); + } + + /// A press on a row focuses AND activates it — one click changes the value, the way a + /// row that is its own control should behave. + #[test] + fn a_press_on_a_row_focuses_and_cycles_it() { + fake_home(); + let mut s = SettingsScreen::with_profiles(Vec::new()); + rendered(&mut s); + // Row 0 of the leading tab is Resolution, whose first step is Native → Match + // window: one field, one unambiguous effect to assert on. + assert_eq!(s.row_ids()[0], RowId::Resolution); + let first = s.list.row_rect(0).expect("the list drew its rows"); + let (mut settings, pads) = ctx_parts(); + settings.save(); // seat the fake HOME's file — `apply_row` rebases on it + 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 fx = Outbox::default(); + assert!(!ctx.settings.match_window); + assert!(s.pointer(press(first), &mut ctx, &mut fx)); + assert_eq!(s.list.cursor, 0, "the pressed row takes focus"); + assert!( + ctx.settings.match_window, + "one press both focuses the row and cycles its value" + ); + } + + /// A press that lands on neither a pill nor a row is refused, so the shell can let it + /// fall through rather than swallowing every stray click. + #[test] + fn a_press_on_empty_space_is_not_consumed() { + let mut s = SettingsScreen::with_profiles(Vec::new()); + rendered(&mut s); + with_ctx(|ctx| { + let mut fx = Outbox::default(); + let p = Pointer { + x: 4.0, + y: 780.0, + kind: crate::pointer::PointerKind::Press, + }; + assert!(!s.pointer(p, ctx, &mut fx)); + }); + } + #[test] fn adjust_clamps_and_activate_wraps() { let (mut settings, pads) = ctx_parts(); diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index d5447d90..81af9205 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -13,6 +13,7 @@ use crate::anim::Progress; use crate::glyphs::GlyphStyle; use crate::library::{mesh_sksl, palette, LibraryShared}; use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; +use crate::pointer::{Pointer, PointerKind}; use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen}; use anyhow::{anyhow, Result}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo}; @@ -106,6 +107,10 @@ pub(crate) struct Shell { glyphs: GlyphStyle, chip: Option, pads: Vec, + /// The settled top screen's hint-bar hit boxes, republished every frame by + /// [`Shell::render`]. The legend is the console's only on-screen statement of what the + /// face buttons do; for a pointer, which has none, it IS the button bar. + hint_rects: Vec<(crate::glyphs::HintKey, Rect)>, t0: Instant, last_frame: Option, } @@ -152,6 +157,7 @@ impl Shell { glyphs: GlyphStyle::Keyboard, chip: None, pads: Vec::new(), + hint_rects: Vec::new(), t0: Instant::now(), last_frame: None, }) @@ -416,10 +422,81 @@ impl Shell { pulse } + /// Mouse and touch, in device pixels. `true` = consumed. + /// + /// The precedence mirrors [`Self::handle_menu`] exactly, and for the same reasons: a + /// modal card owns input while it is up, and a screen in motion takes none at all. The + /// one addition is the hint bar, which sits above the screens because a pointer has no + /// face buttons and the legend is where those actions live. + pub(crate) fn pointer(&mut self, p: Pointer) -> bool { + self.sync(); + // The right button is the pointer's B, everywhere — including on the modal cards, + // where Back is the only thing that answers at all. + // + // With ONE exception: B at the root quits the launcher, and a right-click is far + // easier to fire by accident than a thumb on B. Quitting stays an explicit act — + // the legend's "Quit" is clickable, and that is the pointer's way out. + if p.kind == PointerKind::Back { + if self.stack.len() > 1 || self.connecting.is_some() || self.wake.is_some() { + self.handle_menu(MenuEvent::Back); + } + return true; + } + // A modal swallows the rest: clicking "past" a connect takeover onto the library + // behind it would start a second session, which is the same hole the menu path + // closes by returning early here. + if self.connecting.is_some() || self.wake.is_some() { + return true; + } + if !matches!(self.motion, Motion::None) { + return true; + } + 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 + // 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. + let ev = match key { + crate::glyphs::HintKey::Confirm => Some(MenuEvent::Confirm), + crate::glyphs::HintKey::Back => Some(MenuEvent::Back), + crate::glyphs::HintKey::Secondary => Some(MenuEvent::Secondary), + crate::glyphs::HintKey::Tertiary => Some(MenuEvent::Tertiary), + _ => None, + }; + if let Some(ev) = ev { + self.handle_menu(ev); + } + return true; + } + } + + let mut fx = Outbox::default(); + let consumed = { + let mut ctx = Ctx { + hosts: &self.hosts, + library: &self.library, + settings: &mut self.settings, + pads: &self.pads, + deck: self.deck, + device_name: &self.device_name, + t: self.t0.elapsed().as_secs_f64(), + }; + self.stack + .last_mut() + .expect("non-empty stack") + .pointer(p, &mut ctx, &mut fx) + }; + self.apply(fx); + consumed + } + /// The keyboard fallback — the console is fully drivable with no pad. Arrows and /// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary /// (suppressed while editing, where letters are text). - pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool { + /// + /// `shift` only matters for Tab, whose two directions are one key. + pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, shift: bool, repeat: bool) -> bool { use sdl3::keyboard::Scancode as S; if self.editing() { if let Some(top) = self.stack.last_mut() { @@ -439,6 +516,12 @@ impl Shell { S::Escape | S::Backspace if !repeat => MenuEvent::Back, S::PageUp if !repeat => MenuEvent::JumpBack, S::PageDown if !repeat => MenuEvent::JumpForward, + // Tab is what a keyboard reaches for to change section, and the settings tabs + // were otherwise on PgUp/PgDn alone — a binding the legend only ever spells out + // when NO pad is attached, so with a controller plugged in there was nothing to + // discover. Shift+Tab goes back, as everywhere else. + S::Tab if !repeat && shift => MenuEvent::JumpBack, + S::Tab if !repeat => MenuEvent::JumpForward, S::Y if !repeat && !editing => MenuEvent::Secondary, S::X if !repeat && !editing => MenuEvent::Tertiary, _ => return false, @@ -483,6 +566,9 @@ impl Shell { if let Some(text) = fx.toast { self.show_toast(text); } + if let Some(text) = fx.copy { + self.actions.push_back(OverlayAction::CopyText(text)); + } if let Some(intent) = fx.connect { self.start_connect(intent); } @@ -497,6 +583,14 @@ impl Shell { self.stack.push(*screen); self.motion = Motion::Push(Progress::new(TRANSITION_S)); } + 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. + self.stack.pop(); + self.stack.push(*screen); + self.motion = Motion::Push(Progress::new(TRANSITION_S)); + } Nav::Pop => { if self.stack.len() > 1 { let leaving = self.stack.pop().expect("len > 1"); diff --git a/crates/pf-console-ui/src/shell/overlays.rs b/crates/pf-console-ui/src/shell/overlays.rs index 4a042d8c..49075830 100644 --- a/crates/pf-console-ui/src/shell/overlays.rs +++ b/crates/pf-console-ui/src/shell/overlays.rs @@ -219,7 +219,7 @@ impl Shell { fonts, hints, self.glyphs, - cx - probe.0 / 2.0, + cx - probe.size.0 / 2.0, h - 34.0 * k, k, ); diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index a5728d0a..e9c96e08 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -112,6 +112,11 @@ impl Shell { // A modal card owns B/A while it's up — the screen's legend would lie. show_hints: self.connecting.is_none() && self.wake.is_none(), }; + // Only a SETTLED top screen publishes clickable hint boxes. Mid-transition every + // layer is slid and scaled inside a `save_layer`, so the rects a `paint` reports + // aren't where the pixels are — and the shell drops pointer input during a + // transition anyway, exactly as it drops menu events. + self.hint_rects.clear(); match (&mut self.motion, motion_p) { (Motion::Push(_), Some(raw)) => { let p = ease_out_cubic(raw); @@ -141,7 +146,7 @@ impl Shell { } _ => { let n = self.stack.len(); - env.paint(&mut self.stack[n - 1], 1.0, 0.0, 1.0); + self.hint_rects = env.paint(&mut self.stack[n - 1], 1.0, 0.0, 1.0); } } @@ -204,8 +209,15 @@ struct LayerEnv<'a> { impl LayerEnv<'_> { /// One screen composited as a unit: `alpha` fade, `dy` vertical slide, `scale` /// about the screen center — its pinned title and hint bar ride inside the layer, - /// so chrome travels with content through a transition. - fn paint(&mut self, screen: &mut Screen, alpha: f64, dy: f64, scale: f64) { + /// so chrome travels with content through a transition. Returns the hint bar's hit + /// boxes, which only the caller can know are worth keeping (see `Shell::render`). + fn paint( + &mut self, + screen: &mut Screen, + alpha: f64, + dy: f64, + scale: f64, + ) -> Vec<(crate::glyphs::HintKey, Rect)> { let canvas = self.canvas; canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32); canvas.translate((0.0, dy as f32)); @@ -234,7 +246,7 @@ impl LayerEnv<'_> { self.w * 0.7, ); screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx); - if self.show_hints { + let rects = if self.show_hints { let hints = screen.hints(&ctx); hint_bar( canvas, @@ -244,8 +256,12 @@ impl LayerEnv<'_> { 18.0 * self.k, self.h - 18.0 * self.k, self.k, - ); - } + ) + .rects + } else { + Vec::new() + }; canvas.restore(); + rects } } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 0c0af972..7752f2c6 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -171,6 +171,71 @@ fn wake_gates_input_in_the_same_press() { /// this nothing in the normal gate ever ran the tab strip's layout arithmetic or a settings /// screen's rows — a bad index there would only surface on a Deck. CPU raster: the SkSL /// backdrop, the layers and the text all run without a GPU. +/// Tab / Shift+Tab change section. The strip shipped on the shoulder buttons and +/// PgUp/PgDn only, and the legend names PgUp/PgDn solely when NO pad is attached — so with +/// a controller plugged in a keyboard user had no way in, and no way to find one. +#[test] +fn tab_and_shift_tab_change_section() { + use sdl3::keyboard::Scancode; + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.handle_menu(MenuEvent::Tertiary); // X → Settings + s.motion = Motion::None; // skip the push transition, which drops input + let tab = |s: &Shell| match s.stack.last() { + Some(Screen::Settings(st)) => st.tab_for_test(), + _ => panic!("the settings screen is on top"), + }; + assert_eq!(tab(&s), 0); + assert!(s.key(Scancode::Tab, false, false), "Tab is consumed"); + assert_eq!(tab(&s), 1, "Tab goes forward"); + assert!(s.key(Scancode::Tab, true, false)); + assert_eq!(tab(&s), 0, "Shift+Tab goes back"); + // …and it wraps backwards off the first tab, exactly as the shoulders do. + s.key(Scancode::Tab, true, false); + assert_eq!(tab(&s), crate::screens::settings::TAB_COUNT - 1); + // A key repeat must not run through the strip a section per frame held. + let before = tab(&s); + s.key(Scancode::Tab, false, true); + assert_eq!(tab(&s), before, "held Tab doesn't skip sections"); +} + +/// A right-click is Back on every screen, so a pointer always has a way out. +#[test] +fn a_secondary_press_goes_back() { + let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]); + s.handle_menu(MenuEvent::Tertiary); // X → Settings + s.motion = Motion::None; + assert_eq!(s.stack.len(), 2); + assert!(s.pointer(crate::pointer::Pointer { + x: 10.0, + y: 10.0, + kind: crate::pointer::PointerKind::Back, + })); + // The pop runs through the same transition a B press does. + assert!(matches!(s.motion, Motion::Pop { .. })); +} + +/// 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() { + 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(_))), + "the first tile is a saved host" + ); + s.motion = Motion::None; + s.handle_menu(MenuEvent::Back); + s.motion = Motion::None; + // The third fixture host is discovered-only (`saved: false`). + s.handle_menu(MenuEvent::Move(MenuDir::Right)); + s.handle_menu(MenuEvent::Move(MenuDir::Right)); + s.handle_menu(MenuEvent::Move(MenuDir::Up)); + assert!( + matches!(s.stack.last(), Some(Screen::Home(_))), + "an unsaved host has nothing to edit or forget" + ); +} + #[test] fn every_settings_tab_rasters() { let fonts = crate::theme::build_fonts().unwrap(); @@ -243,6 +308,13 @@ fn dump_console_screens() { let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]); dump(&mut s, 40, 8, "01-home", true); + // The host menu — Up on the focused saved tile. The home frame above carries the new + // ▲ Options hint that leads here, so the two are worth eyeballing together. + s.handle_menu(MenuEvent::Move(MenuDir::Up)); + dump(&mut s, 40, 8, "01b-host-options", true); + s.handle_menu(MenuEvent::Back); + dump(&mut s, 20, 8, "_settle0", true); + // Mid-push into Settings (the transition still): a couple of fast frames land // the capture around p ≈ 0.4 — both layers visible. s.handle_menu(MenuEvent::Tertiary); diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index 1debc4fa..86ad4e25 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -8,6 +8,7 @@ //! OSD, capture hint, the auto-fading start banner). 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}; @@ -16,7 +17,8 @@ use ash::vk as avk; use ash::vk::Handle as _; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_presenter::overlay::{ - FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice, + FrameCtx, Overlay, OverlayAction, OverlayFrame, PointerButton, PointerInput, SessionPhase, + SharedDevice, }; use skia_safe::gpu::vk as skvk; use skia_safe::gpu::{self, DirectContext, SurfaceOrigin}; @@ -294,7 +296,8 @@ impl Overlay for SkiaOverlay { if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) { return false; } - shell.key(*sc, *repeat) + let shift = keymod.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD); + shell.key(*sc, shift, *repeat) } sdl3::event::Event::TextInput { text, .. } => { shell.text_input(text); @@ -312,6 +315,48 @@ impl Overlay for SkiaOverlay { } } + fn handle_pointer(&mut self, input: PointerInput) -> bool { + if !self.console_visible() { + return false; + } + let Some(shell) = &mut self.shell else { + return false; + }; + // `Up` of the secondary button is dropped rather than mapped: `Down` already sent + // Back, and a second event would pop two screens per right-click. + let (x, y, kind) = match input { + PointerInput::Move { x, y } => (x, y, PointerKind::Move), + PointerInput::Down { + x, + y, + button: PointerButton::Primary, + } => (x, y, PointerKind::Press), + PointerInput::Down { + x, + y, + button: PointerButton::Secondary, + } => (x, y, PointerKind::Back), + PointerInput::Up { + x, + y, + button: PointerButton::Primary, + } => (x, y, PointerKind::Release), + PointerInput::Up { .. } => return true, + PointerInput::Wheel { x, y, dy } => { + if dy == 0.0 { + return true; + } + (x, y, PointerKind::Scroll { up: dy > 0.0 }) + } + PointerInput::Cancel => (0.0, 0.0, PointerKind::Cancel), + }; + shell.pointer(Pointer { + x: f64::from(x), + y: f64::from(y), + kind, + }) + } + fn take_action(&mut self) -> Option { self.shell.as_mut().and_then(|s| s.take_action()) } diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index 916888cd..3f481802 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -6,6 +6,7 @@ use crate::anim::{approach, 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 pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Paint, Path, RRect, Rect}; @@ -87,6 +88,10 @@ pub(crate) struct MenuList { /// Next render, seat the scroll and the focus ease instantly instead of chasing — see /// [`MenuList::jump_to`]. snap: bool, + /// Each row's rect as the last frame actually drew it, device px — what a pointer hit- + /// tests against. One entry per row, `Rect::new_empty()` for rows scrolled out of view, + /// so an index into this is an index into `rows`. + geom: Vec, } impl MenuList { @@ -97,6 +102,7 @@ impl MenuList { scroll: 0.0, focus: Vec::new(), snap: true, + geom: Vec::new(), } } @@ -121,6 +127,33 @@ impl MenuList { } } + /// A row's drawn rect, for tests that assert on what a press can actually reach. + #[cfg(test)] + pub(crate) fn row_rect(&self, i: usize) -> Option { + self.geom.get(i).copied().filter(|r| !r.is_empty()) + } + + /// Route a pointer. A press picks the row under it, focuses it AND activates it — + /// one click does what the pad needs a move plus an A for, which is what a mouse user + /// expects of a row that IS its control ("click Resolution, resolution changes"). + /// Because activation wraps, every value stays reachable by clicking alone. + /// + /// A press in the list's empty margin is swallowed, not passed on: it must not fall + /// through to whatever the screen draws behind the list. + pub(crate) fn pointer(&mut self, p: Pointer, len: usize) -> (ListMsg, Option) { + match p.kind { + PointerKind::Scroll { up } => (ListMsg::None, self.step(if up { -1 } else { 1 }, len)), + PointerKind::Press => match p.pick(&self.geom) { + Some(i) if i < len => { + self.cursor = i; + (ListMsg::Activate, Some(MenuPulse::Confirm)) + } + _ => (ListMsg::None, None), + }, + _ => (ListMsg::None, None), + } + } + fn step(&mut self, delta: i32, len: usize) -> Option { let target = self.cursor as i32 + delta; if len == 0 || target < 0 || target >= len as i32 { @@ -192,6 +225,8 @@ impl MenuList { canvas.save(); canvas.clip_rect(rect, None, true); + self.geom.clear(); + self.geom.resize(rows.len(), Rect::new_empty()); for (i, row) in rows.iter().enumerate() { let f = self.focus[i]; let top = f64::from(rect.top) + tops[i] * k - self.scroll + self.bump.pos * k; @@ -220,6 +255,9 @@ impl MenuList { canvas.scale((scale as f32, scale as f32)); canvas.translate((-cx as f32, -cy as f32)); let r = Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32); + // The untransformed rect: the focus scale is a 2 % breath about the centre, far + // inside the slop a finger brings, and clicking must not depend on the ease. + self.geom[i] = r; let stroke = if row.caret { PanelStroke::Brand(0.7) } else { @@ -309,11 +347,31 @@ pub(crate) struct TabStrip { /// Chased highlight geometry `(x, width)` in device px. `None` until the first render, /// so a freshly opened screen doesn't animate its highlight in from x = 0. indicator: Option<(f64, f64)>, + /// Each pill's rect as last drawn, device px — the strip is the one part of a settings + /// screen a pointer can reach directly, so it hit-tests against what it drew. + pills: Vec, } impl TabStrip { pub(crate) fn new() -> TabStrip { - TabStrip { indicator: None } + TabStrip { + indicator: None, + pills: Vec::new(), + } + } + + /// A pill's drawn rect, for tests that assert on what a press can actually reach. + #[cfg(test)] + pub(crate) fn pill(&self, i: usize) -> Option { + self.pills.get(i).copied() + } + + /// The tab a press landed on, if any. Pills are small, so the hit box is the full + /// strip height rather than the drawn pill — a tap that lands just above or below the + /// text still selects, which on a touchscreen is the difference between working and + /// not. + pub(crate) fn pointer(&self, p: Pointer) -> Option { + p.press().then(|| p.pick(&self.pills)).flatten() } /// Draw the pills centered in `rect`'s top band. Returns nothing — the caller already @@ -368,10 +426,19 @@ impl TabStrip { ); let baseline = top + pill_h / 2.0 + size * 0.36; + self.pills.clear(); for (i, label) in labels.iter().enumerate() { // Fade each label toward white by how much the highlight actually covers it, so // the two labels a sliding highlight passes between light up together. let pill_x = x; + // Full-height hit box (see `TabStrip::pointer`), and only ever grown from the + // pill's own span so two neighbours can't both claim a press. + self.pills.push(Rect::from_xywh( + pill_x as f32, + rect.top, + widths[i] as f32, + rect.height().max((pill_h + 4.0 * k) as f32), + )); let overlap = (pill_x + widths[i]).min(ix + iw) - pill_x.max(ix); let covered = (overlap / widths[i]).clamp(0.0, 1.0) as f32; let tw = f64::from(fonts.measure(label, W::SemiBold, size)); @@ -479,6 +546,9 @@ pub(crate) struct Keyboard { /// Tray slide-in (0 hidden → 1 seated), the Swift `.spring(0.32, 0.86)`. tray: Spring, key_flash: f64, + /// Each key's rect and identity as last drawn — the tray slides, so hit-testing has to + /// read the drawn geometry rather than recompute a seated layout. + keys: Vec<(Rect, Key)>, } impl Keyboard { @@ -488,9 +558,47 @@ impl Keyboard { col: 0, tray: Spring::rest(0.0), key_flash: 0.0, + keys: Vec::new(), } } + /// Route a pointer at the tray. A press types the key under it and moves the key + /// cursor there, so a pad can carry on from wherever a finger left off. A press that + /// lands on the tray but between keys is swallowed — the tray is modal, and a stray + /// tap must not reach the list behind it. + pub(crate) fn pointer(&mut self, p: Pointer) -> (KeyMsg, Option) { + if !p.press() { + return (KeyMsg::None, None); + } + let Some(i) = p.pick(&self.keys.iter().map(|(r, _)| *r).collect::>()) else { + return (KeyMsg::None, None); + }; + let key = self.keys[i].1; + // Re-seat the cursor from the key's identity, not the draw index: `key_rows` is the + // one layout authority and the two must not be able to drift apart. + if let Some((r, c)) = key_rows() + .iter() + .enumerate() + .find_map(|(r, row)| row.iter().position(|k| *k == key).map(|c| (r, c))) + { + self.row = r; + self.col = c; + } + self.key_flash = 1.0; + match key { + Key::Char(c) => (KeyMsg::Type(c), None), + Key::Space => (KeyMsg::Type(' '), None), + Key::Backspace => (KeyMsg::Backspace, None), + Key::Done => (KeyMsg::Done, Some(MenuPulse::Confirm)), + } + } + + /// Does `p` land on the tray at all? The screen asks before routing, so a press + /// outside a raised keyboard can dismiss it instead of falling through to the list. + pub(crate) fn covers(&self, p: Pointer) -> bool { + self.keys.iter().any(|(r, _)| p.hits(*r)) + } + /// Route a menu event; the SCREEN applies `Type`/`Backspace` to its field (charset /// checks included — a refusal comes back as a boundary pulse from the screen). pub(crate) fn menu(&mut self, ev: MenuEvent) -> (KeyMsg, Option) { @@ -559,7 +667,7 @@ impl Keyboard { /// `seat` (0..1). The caller clips nothing — the tray rises from below the screen. #[allow(clippy::too_many_arguments)] pub(crate) fn render( - &self, + &mut self, canvas: &Canvas, fonts: &Fonts, w: f64, @@ -568,6 +676,7 @@ impl Keyboard { k: f64, ) { let rows = key_rows(); + self.keys.clear(); let tray_w = (560.0 * k).min(w - 32.0 * k); let tray_h = Self::tray_height() * k; let x0 = (w - tray_w) / 2.0; @@ -593,6 +702,7 @@ impl Keyboard { let x = x0 + pad + c as f64 * (key_w + gap); 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 mut b = accent(1.0); if self.key_flash > 0.02 { diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index d44df629..13748e52 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -102,6 +102,53 @@ pub enum OverlayAction { CancelConnect, /// Quit the launcher (B at the root) — ends the process, Gaming Mode returns. Quit, + /// Put this text on the system clipboard (the host menu's "Copy link"). An action + /// rather than a console command because the clipboard belongs to SDL, which lives on + /// the run loop's thread and nowhere else. + CopyText(String), +} + +/// Which button a [`PointerInput`] press/release carries. A touchscreen contact always +/// arrives as `Primary` — there is no second finger-button, and the console's back +/// affordance is on glass. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PointerButton { + Primary, + /// The right button — the console reads it as Back, the pointer's B. + Secondary, +} + +/// Pointer or touch input offered to the overlay, in SWAPCHAIN PIXELS. +/// +/// Pixels, not window coordinates, because that is the space the overlay renders in: a +/// screen hit-tests the very rects it drew last frame instead of re-deriving a layout +/// through the display scale. The run loop owns the conversion — it is the side that +/// holds the window. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PointerInput { + Move { + x: f32, + y: f32, + }, + Down { + x: f32, + y: f32, + button: PointerButton, + }, + Up { + x: f32, + y: f32, + button: PointerButton, + }, + /// One wheel/trackpad scroll step at `x`/`y`; `dy` > 0 scrolls away from the user. + Wheel { + x: f32, + y: f32, + dy: f32, + }, + /// The gesture was abandoned (the pointer left the window, the touch was canceled) — + /// any armed press is dropped without acting. + Cancel, } /// Session lifecycle notifications into the overlay (browse mode drives its scenes off @@ -142,6 +189,17 @@ pub trait Overlay { None } + /// Mouse/touch input, in swapchain pixels, before capture sees it. `true` = consumed + /// (the console is up and something under the pointer took it) — the event must not + /// reach capture/forwarding. + /// + /// Separate from [`Self::handle_event`] because the window→pixel conversion belongs to + /// the run loop, which is the side that holds the window: the overlay renders in + /// pixels and would otherwise have to re-derive the display scale it never sees. + fn handle_pointer(&mut self, _input: PointerInput) -> bool { + false + } + /// Drain one pending action raised by handled input. Called once per loop /// iteration; return `None` when idle. fn take_action(&mut self) -> Option { diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 434c2dc6..93a76348 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -17,7 +17,9 @@ //! D disconnect, S stats tier, V microphone mute. use crate::input::{Capture, FingerPhase}; -use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase}; +use crate::overlay::{ + FrameCtx, Overlay, OverlayAction, OverlayFrame, PointerButton, PointerInput, SessionPhase, +}; use crate::present_pace::{ Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS, }; @@ -718,6 +720,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if o.handle_event(&event) { continue; } + // …and the same for mouse/touch, which the console hit-tests in its own + // pixel space. Consumed while the console is up; ignored while streaming, + // where these belong to `Capture` below. + if let Some(input) = overlay_pointer(&event, &window) { + if o.handle_pointer(input) { + continue; + } + } } match event { Event::Quit { .. } => { @@ -1198,6 +1208,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } } } + // The console already toasted "Link copied"; a clipboard SDL refuses is + // worth a log line but not worth contradicting the toast over. + OverlayAction::CopyText(text) => { + if let Err(e) = video.clipboard().set_clipboard_text(&text) { + tracing::warn!(error = %e, "copying to the clipboard"); + } + } action => { let force_software = Arc::new(AtomicBool::new(false)); match on_action( @@ -2360,6 +2377,82 @@ fn apply_capture( } } +/// One SDL mouse/touch event as the overlay wants it: swapchain PIXELS, which is the +/// space the console renders and hit-tests in. `None` for events the console can't use. +/// +/// Two different conversions, and mixing them up puts every click off by the display +/// scale: SDL reports mouse positions in WINDOW coordinates (logical units — 1× on a +/// HiDPI panel at 200 % is half a pixel), while fingers arrive window-NORMALIZED (0..1). +/// Only DIRECT touch devices are offered; an indirect trackpad already drives the mouse, +/// and forwarding both would double every tap. +fn overlay_pointer(event: &Event, window: &sdl3::video::Window) -> Option { + let (pw, ph) = window.size_in_pixels(); + let (lw, lh) = window.size(); + // Logical → physical. A zero-sized window (minimized) would divide by zero. + let sx = pw as f32 / lw.max(1) as f32; + let sy = ph as f32 / lh.max(1) as f32; + let button = |b: sdl3::mouse::MouseButton| match b { + sdl3::mouse::MouseButton::Left => Some(PointerButton::Primary), + sdl3::mouse::MouseButton::Right => Some(PointerButton::Secondary), + _ => None, + }; + Some(match event { + Event::MouseMotion { x, y, .. } => PointerInput::Move { + x: x * sx, + y: y * sy, + }, + Event::MouseButtonDown { + mouse_btn, x, y, .. + } => PointerInput::Down { + x: x * sx, + y: y * sy, + button: button(*mouse_btn)?, + }, + Event::MouseButtonUp { + mouse_btn, x, y, .. + } => PointerInput::Up { + x: x * sx, + y: y * sy, + button: button(*mouse_btn)?, + }, + Event::MouseWheel { + y, + mouse_x, + mouse_y, + .. + } => PointerInput::Wheel { + x: mouse_x * sx, + y: mouse_y * sy, + dy: *y, + }, + Event::FingerDown { touch_id, x, y, .. } if is_direct_touch(*touch_id) => { + PointerInput::Down { + x: x * pw as f32, + y: y * ph as f32, + button: PointerButton::Primary, + } + } + Event::FingerMotion { touch_id, x, y, .. } if is_direct_touch(*touch_id) => { + PointerInput::Move { + x: x * pw as f32, + y: y * ph as f32, + } + } + Event::FingerUp { touch_id, x, y, .. } if is_direct_touch(*touch_id) => PointerInput::Up { + x: x * pw as f32, + y: y * ph as f32, + button: PointerButton::Primary, + }, + // The pointer left the window mid-press: drop the press rather than let a release + // that never comes leave a widget armed forever. + Event::Window { + win_event: WindowEvent::MouseLeave, + .. + } => PointerInput::Cancel, + _ => return None, + }) +} + /// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)? /// Trackpads report INDIRECT and drive the mouse — their finger events must not be /// forwarded as touch passthrough. An unknown/invalid id (INVALID) reads as not-direct.