From f06b3d9d047182b149f503e022d93f554a0786ff Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 12:36:22 +0200 Subject: [PATCH 1/2] feat(console): the console answers a mouse and a finger, and host cards get a menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both found on the shared Linux/Windows console UI. **The settings tabs only moved for a gamepad.** They were bound to the shoulder buttons and to PgUp/PgDn, and the legend spells PgUp/PgDn out only when NO pad is attached — so with a controller plugged in a keyboard user had nothing to find, and a mouse or a touchscreen could not change section at all. The root cause was wider than the strip: `SkiaOverlay::handle_event` matched only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console into the run loop, which routes pointer input exclusively at `stream.capture` — `None` while you are browsing. Nothing in the console had ever been clickable. Making just the pills answer would not have helped either: the settings screen is opened with X from home, so a mouse could not reach it. So the console gets a real pointer path: - `Overlay::handle_pointer` carries mouse/touch in SWAPCHAIN PIXELS. The run loop converts (it owns the window, hence the display scale, and mouse coordinates are logical while fingers are normalised); the console then hit-tests the very rects it drew last frame. Only DIRECT touch devices are offered — an indirect trackpad already drives the mouse. - Widgets act on the PRESS, not the release. The list and both carousels scroll the focused item toward the centre, so what you pressed has slid out from under your finger by the time it lifts; press-to-act has no such race and there is no drag gesture to compete with. - The hint bar became the pointer's button bar. It is already the console's only on-screen statement of what the face buttons do, and a pointer has none — so its Confirm/Back/Secondary/Tertiary pills are clickable on every screen, which is what puts Settings and Library within reach of a mouse at all. - Tab / Shift+Tab change section; PgUp/PgDn still do, and the keyboard legend now reads "Tab". - Right-click is Back everywhere, EXCEPT at the root: B there quits the launcher and a right-click is far easier to fire by accident. Quitting stays explicit. **Host cards had no menu.** Every other client hangs Wake / Copy link / Edit / Forget off a host card; the console could add a host and connect to one, and that was all — so a renamed machine or a fat-fingered address stayed wrong forever unless you opened a desktop shell. UP on a saved tile now opens that host's menu, the same gesture the Android console uses, on the one direction a horizontal carousel leaves free. - `ConsoleCmd::UpdateHost` edits the stored host IN PLACE. Removing and re-adding would silently drop the fingerprint, the learned MAC, the pinned cards and the profile binding — that is a rename, not a re-pair. - `ConsoleCmd::ForgetHost` drops it; if it is still advertising it returns as a discovered, unpaired row, which is the honest state. - Forget arms on the first press and fires on the second. The other clients forget outright; a console is driven by a thumbstick from across a room. - 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 exactly the distinction a pin draws. - "Edit…" REPLACES the menu on the stack rather than stacking over it, so Back from the editor doesn't land on a menu describing the host as it was before the edit. Verified in the pf-lxcheck2 container (this crate compiles to nothing on macOS — a bare `cargo check` there is vacuous): plain build and `clippy --all-targets` clean under `-D warnings`, 72 tests pass. Seven are new, and cover the reported bug directly — a press on a pill selects that tab, and each tab still keeps its own cursor when a pointer is what switched it. --- clients/session/src/console.rs | 71 +++- crates/pf-console-ui/src/glyphs.rs | 60 ++- crates/pf-console-ui/src/lib.rs | 2 + crates/pf-console-ui/src/model.rs | 12 + crates/pf-console-ui/src/pointer.rs | 100 +++++ crates/pf-console-ui/src/screens.rs | 59 ++- crates/pf-console-ui/src/screens/add_host.rs | 132 ++++++- crates/pf-console-ui/src/screens/home.rs | 65 ++++ .../pf-console-ui/src/screens/host_options.rs | 344 ++++++++++++++++++ crates/pf-console-ui/src/screens/library.rs | 50 +++ crates/pf-console-ui/src/screens/pair.rs | 47 +++ crates/pf-console-ui/src/screens/pin_hosts.rs | 23 ++ crates/pf-console-ui/src/screens/settings.rs | 212 ++++++++++- crates/pf-console-ui/src/shell.rs | 96 ++++- crates/pf-console-ui/src/shell/overlays.rs | 2 +- crates/pf-console-ui/src/shell/render.rs | 28 +- crates/pf-console-ui/src/shell/tests.rs | 72 ++++ crates/pf-console-ui/src/skia_overlay.rs | 49 ++- crates/pf-console-ui/src/widgets.rs | 114 +++++- crates/pf-presenter/src/overlay.rs | 58 +++ crates/pf-presenter/src/run.rs | 95 ++++- 21 files changed, 1643 insertions(+), 48 deletions(-) create mode 100644 crates/pf-console-ui/src/pointer.rs create mode 100644 crates/pf-console-ui/src/screens/host_options.rs 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..424c22d0 --- /dev/null +++ b/crates/pf-console-ui/src/screens/host_options.rs @@ -0,0 +1,344 @@ +//! 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::Fonts; +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, + ) { + let rows: Vec = self + .actions() + .into_iter() + .map(|a| RowSpec::action(self.label(a), true)) + .collect(); + self.list.render(canvas, 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. From d49f955171da1c21c59a2a63c613f722e3eca9cc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 13:29:59 +0200 Subject: [PATCH 2/2] fix(console/host menu): the actions get their explainer line and room to breathe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rows sat hard against the pinned title — the menu is the one form screen with no subtitle, so the list started at the very top of the content band. It now wears Add Host's explainer, which both says what the menu is for and is the air that keeps the first row off the title. A pinned card explains what unpinning does and does not touch, the same wording the Android dialog uses. Caught by the screenshot pass, not by a test. --- .../pf-console-ui/src/screens/host_options.rs | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/pf-console-ui/src/screens/host_options.rs b/crates/pf-console-ui/src/screens/host_options.rs index 424c22d0..f8c5d30d 100644 --- a/crates/pf-console-ui/src/screens/host_options.rs +++ b/crates/pf-console-ui/src/screens/host_options.rs @@ -17,7 +17,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::{ConsoleCmd, HostRow}; use crate::pointer::Pointer; use crate::screens::{Ctx, Outbox, Screen}; -use crate::theme::Fonts; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -219,12 +219,38 @@ impl HostOptionsScreen { 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, rect, &rows, fonts, k, dt, true); + self.list + .render(canvas, list_rect, &rows, fonts, k, dt, true); } }