The console answers a mouse and a finger, and host cards get a menu #90

Merged
enricobuehler merged 2 commits from worktree-console-tabs-pointer into main 2026-08-07 11:38:43 +00:00
21 changed files with 1669 additions and 48 deletions
+63 -8
View File
@@ -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<usize> {
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;
+55 -5
View File
@@ -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;
+2
View File
@@ -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;
+12
View File
@@ -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.
+100
View File
@@ -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<usize> {
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);
}
}
+58 -1
View File
@@ -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<Screen>),
/// 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<Screen>),
}
/// 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<ConnectIntent>,
pub cmds: Vec<ConsoleCmd>,
pub toast: Option<String>,
/// 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<String>,
}
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<String> {
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),
}
}
}
+115 -17
View File
@@ -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<Field>,
/// `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<String>,
}
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<Hint> {
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()),
]
}
}
+65
View File
@@ -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<String>,
/// 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<Rect>,
}
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<MenuPulse> {
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));
@@ -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<Action> {
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<MenuPulse> {
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<MenuPulse>,
actions: &[Action],
_ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
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<Hint> {
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<RowSpec> = 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");
}
}
@@ -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<LibraryGame>,
// 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<Rect>,
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<MenuPulse> {
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<usize> = (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];
+47
View File
@@ -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<MenuPulse>,
roles: &[Role],
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
match msg {
ListMsg::Activate => {
match roles.get(self.list.cursor) {
@@ -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<MenuPulse>,
indices: &[usize],
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
let Some(&host_idx) = indices.get(self.list.cursor) else {
return pulse;
};
+208 -4
View File
@@ -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<MenuPulse> {
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<MenuPulse> {
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<MenuPulse>,
ids: &[RowId],
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
// 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<std::path::PathBuf> = 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();
+95 -1
View File
@@ -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<String>,
pads: Vec<PadInfo>,
/// 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<Instant>,
}
@@ -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");
+1 -1
View File
@@ -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,
);
+22 -6
View File
@@ -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
}
}
+72
View File
@@ -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);
+47 -2
View File
@@ -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<OverlayAction> {
self.shell.as_mut().and_then(|s| s.take_action())
}
+112 -2
View File
@@ -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<Rect>,
}
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<Rect> {
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<MenuPulse>) {
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<MenuPulse> {
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<Rect>,
}
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<Rect> {
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<usize> {
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<MenuPulse>) {
if !p.press() {
return (KeyMsg::None, None);
}
let Some(i) = p.pick(&self.keys.iter().map(|(r, _)| *r).collect::<Vec<_>>()) 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<MenuPulse>) {
@@ -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 {
+58
View File
@@ -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<OverlayAction> {
+94 -1
View File
@@ -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<Option<Outcome>
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<Option<Outcome>
}
}
}
// 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<PointerInput> {
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.