Merge pull request 'The console answers a mouse and a finger, and host cards get a menu' (#90) from worktree-console-tabs-pointer into main
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m42s
ci / rust-arm64 (push) Successful in 3m18s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
deb / build-publish (push) Successful in 4m51s
docker / deploy-docs (push) Successful in 34s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
apple / screenshots (push) Successful in 5m53s
deb / build-publish-host (push) Successful in 7m16s
arch / build-publish (push) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m20s
android / android (push) Failing after 11m58s
flatpak / build-publish (push) Failing after 12m26s
ci / rust (push) Successful in 17m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m14s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m42s
ci / rust-arm64 (push) Successful in 3m18s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
deb / build-publish (push) Successful in 4m51s
docker / deploy-docs (push) Successful in 34s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
apple / screenshots (push) Successful in 5m53s
deb / build-publish-host (push) Successful in 7m16s
arch / build-publish (push) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m20s
android / android (push) Failing after 11m58s
flatpak / build-publish (push) Failing after 12m26s
ci / rust (push) Successful in 17m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m14s
Reviewed-on: #90
This commit was merged in pull request #90.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user