The console answers a mouse and a finger, and host cards get a menu #90
@@ -266,6 +266,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
ActionOutcome::Start(Box::new(params))
|
ActionOutcome::Start(Box::new(params))
|
||||||
}
|
}
|
||||||
OverlayAction::CancelConnect => ActionOutcome::Handled, // run-loop-side
|
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,
|
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 {
|
fn host_display_name(name: &str, addr: &str) -> String {
|
||||||
if name.trim().is_empty() {
|
if name.trim().is_empty() {
|
||||||
addr.to_string()
|
addr.to_string()
|
||||||
@@ -483,6 +503,48 @@ impl ServiceState {
|
|||||||
}
|
}
|
||||||
self.last_probe = Instant::now() - Duration::from_secs(60); // probe it now
|
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 } => {
|
ConsoleCmd::Wake { key, then_connect } => {
|
||||||
if let Some(c) = self.wake_cancel.take() {
|
if let Some(c) = self.wake_cancel.take() {
|
||||||
c.store(true, Ordering::SeqCst);
|
c.store(true, Ordering::SeqCst);
|
||||||
@@ -534,14 +596,7 @@ impl ServiceState {
|
|||||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||||
// a repeated press inside one refresh window can't double-pin.
|
// a repeated press inside one refresh window can't double-pin.
|
||||||
let mut known = trust::KnownHosts::load();
|
let mut known = trust::KnownHosts::load();
|
||||||
let idx = known
|
let idx = index_for_key(&known, &key);
|
||||||
.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 Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pub(crate) enum HintKey {
|
|||||||
Shoulders,
|
Shoulders,
|
||||||
/// ◀ ▶ — left/right adjusts the focused value.
|
/// ◀ ▶ — left/right adjusts the focused value.
|
||||||
Adjust,
|
Adjust,
|
||||||
|
/// ▲ — up opens the focused item's own menu.
|
||||||
|
Up,
|
||||||
Key(&'static str),
|
Key(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,17 @@ impl Hint {
|
|||||||
const LABEL_SIZE: f64 = 14.0;
|
const LABEL_SIZE: f64 = 14.0;
|
||||||
const BADGE_D: f64 = 22.0; // face-button badge diameter
|
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(
|
pub(crate) fn hint_bar(
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
fonts: &Fonts,
|
fonts: &Fonts,
|
||||||
@@ -71,9 +83,12 @@ pub(crate) fn hint_bar(
|
|||||||
x: f64,
|
x: f64,
|
||||||
bottom: f64,
|
bottom: f64,
|
||||||
k: f64,
|
k: f64,
|
||||||
) -> (f64, f64) {
|
) -> HintBar {
|
||||||
if hints.is_empty() {
|
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 pad = 13.0 * k;
|
||||||
let gap_hint = 18.0 * k;
|
let gap_hint = 18.0 * k;
|
||||||
@@ -111,7 +126,19 @@ pub(crate) fn hint_bar(
|
|||||||
|
|
||||||
let cy = bottom - h / 2.0;
|
let cy = bottom - h / 2.0;
|
||||||
let mut pen = x + pad;
|
let mut pen = x + pad;
|
||||||
|
let mut rects = Vec::with_capacity(hints.len());
|
||||||
for (hint, (gw, lw)) in hints.iter().zip(&widths) {
|
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);
|
draw_glyph(canvas, fonts, hint.key, style, pen, cy, k);
|
||||||
pen += gw + gap_glyph;
|
pen += gw + gap_glyph;
|
||||||
// Baseline centered on the badge (cap height ≈ 0.72 em for Geist).
|
// 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;
|
pen += lw + gap_hint;
|
||||||
}
|
}
|
||||||
(w, h)
|
HintBar {
|
||||||
|
size: (w, h),
|
||||||
|
rects,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
|
||||||
match resolved(key, style) {
|
match resolved(key, style) {
|
||||||
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
|
||||||
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * 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),
|
Resolved::Key(text) => keycap_w(fonts, text, k),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +182,9 @@ enum Resolved {
|
|||||||
Badge(Face),
|
Badge(Face),
|
||||||
Shoulders,
|
Shoulders,
|
||||||
Adjust,
|
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),
|
Key(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +203,11 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
|||||||
HintKey::Back => Resolved::Key("Esc"),
|
HintKey::Back => Resolved::Key("Esc"),
|
||||||
HintKey::Secondary => Resolved::Key("Y"),
|
HintKey::Secondary => Resolved::Key("Y"),
|
||||||
HintKey::Tertiary => Resolved::Key("X"),
|
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::Adjust => Resolved::Adjust,
|
||||||
|
HintKey::Up => Resolved::Up,
|
||||||
HintKey::Key(t) => Resolved::Key(t),
|
HintKey::Key(t) => Resolved::Key(t),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -181,6 +218,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
|
|||||||
HintKey::Secondary => Resolved::Badge(Face::Y),
|
HintKey::Secondary => Resolved::Badge(Face::Y),
|
||||||
HintKey::Shoulders => Resolved::Shoulders,
|
HintKey::Shoulders => Resolved::Shoulders,
|
||||||
HintKey::Adjust => Resolved::Adjust,
|
HintKey::Adjust => Resolved::Adjust,
|
||||||
|
HintKey::Up => Resolved::Up,
|
||||||
HintKey::Key(t) => Resolved::Key(t),
|
HintKey::Key(t) => Resolved::Key(t),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +289,18 @@ fn draw_glyph(
|
|||||||
pen += w + 3.0 * k;
|
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 => {
|
Resolved::Adjust => {
|
||||||
// ◀ ▶ — two small solid triangles.
|
// ◀ ▶ — two small solid triangles.
|
||||||
let r = BADGE_D * k / 2.0;
|
let r = BADGE_D * k / 2.0;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub mod library;
|
|||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
pub mod model;
|
pub mod model;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
mod pointer;
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
mod screens;
|
mod screens;
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
mod shell;
|
mod shell;
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ pub enum ConsoleCmd {
|
|||||||
addr: String,
|
addr: String,
|
||||||
port: u16,
|
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.
|
/// Start the wake-and-wait loop for this saved host.
|
||||||
Wake { key: String, then_connect: bool },
|
Wake { key: String, then_connect: bool },
|
||||||
/// Stop the wake loop (B on the wake card) and clear its status.
|
/// 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 add_host;
|
||||||
pub(crate) mod home;
|
pub(crate) mod home;
|
||||||
|
pub(crate) mod host_options;
|
||||||
pub(crate) mod library;
|
pub(crate) mod library;
|
||||||
pub(crate) mod pair;
|
pub(crate) mod pair;
|
||||||
pub(crate) mod pin_hosts;
|
pub(crate) mod pin_hosts;
|
||||||
@@ -13,6 +14,7 @@ pub(crate) mod settings;
|
|||||||
use crate::glyphs::Hint;
|
use crate::glyphs::Hint;
|
||||||
use crate::library::LibraryShared;
|
use crate::library::LibraryShared;
|
||||||
use crate::model::{ConsoleCmd, HostRow};
|
use crate::model::{ConsoleCmd, HostRow};
|
||||||
|
use crate::pointer::Pointer;
|
||||||
use crate::theme::Fonts;
|
use crate::theme::Fonts;
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
use pf_client_core::{gamepad::PadInfo, trust};
|
use pf_client_core::{gamepad::PadInfo, trust};
|
||||||
@@ -68,6 +70,10 @@ pub(crate) enum Nav {
|
|||||||
Push(Box<Screen>),
|
Push(Box<Screen>),
|
||||||
/// Pop this screen; popping the root quits the console.
|
/// Pop this screen; popping the root quits the console.
|
||||||
Pop,
|
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
|
/// 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 connect: Option<ConnectIntent>,
|
||||||
pub cmds: Vec<ConsoleCmd>,
|
pub cmds: Vec<ConsoleCmd>,
|
||||||
pub toast: Option<String>,
|
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 {
|
impl Outbox {
|
||||||
@@ -88,6 +97,30 @@ impl Outbox {
|
|||||||
pub(crate) fn pop(&mut self) {
|
pub(crate) fn pop(&mut self) {
|
||||||
self.nav = Some(Nav::Pop);
|
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 {
|
pub(crate) enum Screen {
|
||||||
@@ -97,6 +130,9 @@ pub(crate) enum Screen {
|
|||||||
AddHost(add_host::AddHostScreen),
|
AddHost(add_host::AddHostScreen),
|
||||||
Pair(pair::PairScreen),
|
Pair(pair::PairScreen),
|
||||||
PinHosts(pin_hosts::PinHostsScreen),
|
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 {
|
impl Screen {
|
||||||
@@ -113,6 +149,24 @@ impl Screen {
|
|||||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||||
Screen::PinHosts(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::Home(_) => "Select a Host".into(),
|
||||||
Screen::Library(s) => s.host_name().to_string(),
|
Screen::Library(s) => s.host_name().to_string(),
|
||||||
Screen::Settings(_) => "Settings".into(),
|
Screen::Settings(_) => "Settings".into(),
|
||||||
Screen::AddHost(_) => "Add Host".into(),
|
Screen::AddHost(s) => s.title(),
|
||||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_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::AddHost(s) => s.hints(ctx),
|
||||||
Screen::Pair(s) => s.hints(ctx),
|
Screen::Pair(s) => s.hints(ctx),
|
||||||
Screen::PinHosts(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::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||||
Screen::Pair(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::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.
|
//! hardware keyboards type straight into the focused field through SDL text input.
|
||||||
|
|
||||||
use crate::glyphs::{Hint, HintKey};
|
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::screens::{Ctx, Outbox};
|
||||||
use crate::theme::{fg, Fonts, W};
|
use crate::theme::{fg, Fonts, W};
|
||||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||||
@@ -28,6 +29,10 @@ pub(crate) struct AddHostScreen {
|
|||||||
address: String,
|
address: String,
|
||||||
port: String,
|
port: String,
|
||||||
editing: Option<Field>,
|
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 {
|
impl AddHostScreen {
|
||||||
@@ -39,9 +44,71 @@ impl AddHostScreen {
|
|||||||
address: String::new(),
|
address: String::new(),
|
||||||
port: "9777".into(),
|
port: "9777".into(),
|
||||||
editing: None,
|
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 {
|
pub(crate) fn editing(&self) -> bool {
|
||||||
self.editing.is_some()
|
self.editing.is_some()
|
||||||
}
|
}
|
||||||
@@ -157,27 +224,58 @@ impl AddHostScreen {
|
|||||||
let (msg, pulse) = self.list.menu(ev, FIELDS.len() + 1);
|
let (msg, pulse) = self.list.menu(ev, FIELDS.len() + 1);
|
||||||
match msg {
|
match msg {
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
if self.list.cursor < FIELDS.len() {
|
self.activate(msg, fx);
|
||||||
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);
|
|
||||||
}
|
|
||||||
pulse
|
pulse
|
||||||
}
|
}
|
||||||
_ => 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> {
|
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||||
if self.editing.is_some() {
|
if self.editing.is_some() {
|
||||||
if ctx.deck {
|
if ctx.deck {
|
||||||
@@ -270,7 +368,7 @@ impl AddHostScreen {
|
|||||||
),
|
),
|
||||||
field_row("Address", &self.address, "IP or hostname", Field::Address),
|
field_row("Address", &self.address, "IP or hostname", Field::Address),
|
||||||
field_row("Port", &self.port, "9777", Field::Port),
|
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::glyphs::{Hint, HintKey};
|
||||||
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
|
use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K};
|
||||||
use crate::model::{ConsoleCmd, HostRow};
|
use crate::model::{ConsoleCmd, HostRow};
|
||||||
|
use crate::pointer::{Pointer, PointerKind};
|
||||||
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
use crate::screens::{ConnectIntent, Ctx, Outbox, Screen};
|
||||||
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
|
use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W};
|
||||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||||
@@ -29,6 +30,10 @@ pub(crate) struct HomeScreen {
|
|||||||
bump: Spring,
|
bump: Spring,
|
||||||
/// Last-seen tile keys — hosts churn under discovery; focus follows the KEY.
|
/// Last-seen tile keys — hosts churn under discovery; focus follows the KEY.
|
||||||
keys: Vec<String>,
|
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 {
|
impl HomeScreen {
|
||||||
@@ -38,6 +43,7 @@ impl HomeScreen {
|
|||||||
anim: Spring::rest(0.0),
|
anim: Spring::rest(0.0),
|
||||||
bump: Spring::rest(0.0),
|
bump: Spring::rest(0.0),
|
||||||
keys: Vec::new(),
|
keys: Vec::new(),
|
||||||
|
geom: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +142,55 @@ impl HomeScreen {
|
|||||||
fx.pop(); // popping the root = quit (the shell's rule)
|
fx.pop(); // popping the root = quit (the shell's rule)
|
||||||
None
|
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,
|
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> {
|
fn step(&mut self, delta: i32, len: usize, clamp: bool) -> Option<MenuPulse> {
|
||||||
match step_cursor(self.cursor, len, delta, clamp) {
|
match step_cursor(self.cursor, len, delta, clamp) {
|
||||||
StepResult::Moved(to) => {
|
StepResult::Moved(to) => {
|
||||||
@@ -169,6 +220,12 @@ impl HomeScreen {
|
|||||||
if self.focused(ctx.hosts).is_some_and(|h| h.paired && h.saved) {
|
if self.focused(ctx.hosts).is_some_and(|h| h.paired && h.saved) {
|
||||||
hints.push(Hint::new(HintKey::Secondary, "Library"));
|
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::Tertiary, "Settings"));
|
||||||
hints.push(Hint::new(HintKey::Back, "Quit"));
|
hints.push(Hint::new(HintKey::Back, "Quit"));
|
||||||
hints
|
hints
|
||||||
@@ -200,6 +257,8 @@ impl HomeScreen {
|
|||||||
let cy = f64::from(rect.top) + f64::from(rect.height()) / 2.0;
|
let cy = f64::from(rect.top) + f64::from(rect.height()) / 2.0;
|
||||||
|
|
||||||
let len = ctx.hosts.len() + 1;
|
let len = ctx.hosts.len() + 1;
|
||||||
|
self.geom.clear();
|
||||||
|
self.geom.resize(len, Rect::new_empty());
|
||||||
for i in 0..len {
|
for i in 0..len {
|
||||||
let d = i as f64 - self.anim.pos;
|
let d = i as f64 - self.anim.pos;
|
||||||
if d.abs() > 2.6 {
|
if d.abs() > 2.6 {
|
||||||
@@ -215,6 +274,12 @@ impl HomeScreen {
|
|||||||
tile_w as f32,
|
tile_w as f32,
|
||||||
tile_h 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.save();
|
||||||
canvas.translate((cx as f32, cy as f32));
|
canvas.translate((cx as f32, cy as f32));
|
||||||
canvas.scale((scale as f32, scale 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,
|
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
|
||||||
};
|
};
|
||||||
use crate::model::{ConsoleCmd, HostRow};
|
use crate::model::{ConsoleCmd, HostRow};
|
||||||
|
use crate::pointer::{Pointer, PointerKind};
|
||||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||||
use crate::theme::{accent, fg, Fonts, W};
|
use crate::theme::{accent, fg, Fonts, W};
|
||||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||||
@@ -30,6 +31,9 @@ pub(crate) struct LibraryScreen {
|
|||||||
games: Vec<LibraryGame>,
|
games: Vec<LibraryGame>,
|
||||||
// Navigation: the integer cursor is the authority; the eased position chases it.
|
// Navigation: the integer cursor is the authority; the eased position chases it.
|
||||||
cursor: i32,
|
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,
|
anim: Spring,
|
||||||
bump: Spring,
|
bump: Spring,
|
||||||
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
|
/// Decoded posters by game id (decode once; Skia uploads lazily on first draw).
|
||||||
@@ -49,6 +53,7 @@ impl LibraryScreen {
|
|||||||
phase: LibraryPhase::Loading,
|
phase: LibraryPhase::Loading,
|
||||||
games: Vec::new(),
|
games: Vec::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
geom: Vec::new(),
|
||||||
anim: Spring::rest(0.0),
|
anim: Spring::rest(0.0),
|
||||||
bump: Spring::rest(0.0),
|
bump: Spring::rest(0.0),
|
||||||
art: HashMap::new(),
|
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> {
|
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
|
||||||
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
|
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
|
||||||
StepResult::Moved(to) => {
|
StepResult::Moved(to) => {
|
||||||
@@ -326,6 +368,8 @@ impl LibraryScreen {
|
|||||||
// dense side stacks overlap toward the focus.
|
// dense side stacks overlap toward the focus.
|
||||||
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
||||||
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
|
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 {
|
for i in order {
|
||||||
let d = i as f64 - pos;
|
let d = i as f64 - pos;
|
||||||
@@ -342,6 +386,12 @@ impl LibraryScreen {
|
|||||||
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
|
d.signum() * (FOCUS_GAP + (a - 1.0) * SIDE_SPACING) * k
|
||||||
};
|
};
|
||||||
let ccx = f64::from(rect.left) + w / 2.0 + offset + bump;
|
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 m = card_matrix(ccx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
|
||||||
|
|
||||||
let game = &self.games[i];
|
let game = &self.games[i];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::glyphs::{Hint, HintKey};
|
use crate::glyphs::{Hint, HintKey};
|
||||||
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
use crate::model::{ConsoleCmd, HostRow, PairPhase};
|
||||||
|
use crate::pointer::Pointer;
|
||||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||||
use crate::theme::{fg, Fonts, ERROR, W};
|
use crate::theme::{fg, Fonts, ERROR, W};
|
||||||
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec};
|
||||||
@@ -206,6 +207,52 @@ impl PairScreen {
|
|||||||
}
|
}
|
||||||
let roles = self.roles();
|
let roles = self.roles();
|
||||||
let (msg, pulse) = self.list.menu(ev, roles.len());
|
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 {
|
match msg {
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
match roles.get(self.list.cursor) {
|
match roles.get(self.list.cursor) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use crate::glyphs::{Hint, HintKey};
|
use crate::glyphs::{Hint, HintKey};
|
||||||
use crate::model::ConsoleCmd;
|
use crate::model::ConsoleCmd;
|
||||||
|
use crate::pointer::Pointer;
|
||||||
use crate::screens::{Ctx, Outbox};
|
use crate::screens::{Ctx, Outbox};
|
||||||
use crate::theme::{fg, Fonts, W};
|
use crate::theme::{fg, Fonts, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
@@ -67,6 +68,28 @@ impl PinHostsScreen {
|
|||||||
}
|
}
|
||||||
let indices = host_indices(ctx);
|
let indices = host_indices(ctx);
|
||||||
let (msg, pulse) = self.list.menu(ev, indices.len());
|
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 {
|
let Some(&host_idx) = indices.get(self.list.cursor) else {
|
||||||
return pulse;
|
return pulse;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
//! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with
|
//! 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
|
//! 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.
|
//! 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::glyphs::{Hint, HintKey};
|
||||||
|
use crate::pointer::Pointer;
|
||||||
use crate::screens::{Ctx, Outbox, Screen};
|
use crate::screens::{Ctx, Outbox, Screen};
|
||||||
use crate::theme::{fg, Fonts, W};
|
use crate::theme::{fg, Fonts, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
|
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
|
#[cfg(test)]
|
||||||
/// each tab's own cursor.
|
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> {
|
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||||
self.tab_cursors[self.tab] = self.list.cursor;
|
|
||||||
let n = TABS.len() as i32;
|
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.
|
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
||||||
let len = self.row_ids().len();
|
let len = self.row_ids().len();
|
||||||
self.list
|
self.list
|
||||||
@@ -280,6 +298,22 @@ impl SettingsScreen {
|
|||||||
Some(MenuPulse::Move)
|
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(
|
pub(crate) fn menu(
|
||||||
&mut self,
|
&mut self,
|
||||||
ev: MenuEvent,
|
ev: MenuEvent,
|
||||||
@@ -297,6 +331,19 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
let ids = self.row_ids();
|
let ids = self.row_ids();
|
||||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
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.
|
// The Profiles rows navigate instead of editing the settings file.
|
||||||
match ids[self.list.cursor] {
|
match ids[self.list.cursor] {
|
||||||
RowId::Profile(i) => {
|
RowId::Profile(i) => {
|
||||||
@@ -932,6 +979,163 @@ mod tests {
|
|||||||
(Settings::default(), Vec::new())
|
(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]
|
#[test]
|
||||||
fn adjust_clamps_and_activate_wraps() {
|
fn adjust_clamps_and_activate_wraps() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::anim::Progress;
|
|||||||
use crate::glyphs::GlyphStyle;
|
use crate::glyphs::GlyphStyle;
|
||||||
use crate::library::{mesh_sksl, palette, LibraryShared};
|
use crate::library::{mesh_sksl, palette, LibraryShared};
|
||||||
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||||
|
use crate::pointer::{Pointer, PointerKind};
|
||||||
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
||||||
@@ -106,6 +107,10 @@ pub(crate) struct Shell {
|
|||||||
glyphs: GlyphStyle,
|
glyphs: GlyphStyle,
|
||||||
chip: Option<String>,
|
chip: Option<String>,
|
||||||
pads: Vec<PadInfo>,
|
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,
|
t0: Instant,
|
||||||
last_frame: Option<Instant>,
|
last_frame: Option<Instant>,
|
||||||
}
|
}
|
||||||
@@ -152,6 +157,7 @@ impl Shell {
|
|||||||
glyphs: GlyphStyle::Keyboard,
|
glyphs: GlyphStyle::Keyboard,
|
||||||
chip: None,
|
chip: None,
|
||||||
pads: Vec::new(),
|
pads: Vec::new(),
|
||||||
|
hint_rects: Vec::new(),
|
||||||
t0: Instant::now(),
|
t0: Instant::now(),
|
||||||
last_frame: None,
|
last_frame: None,
|
||||||
})
|
})
|
||||||
@@ -416,10 +422,81 @@ impl Shell {
|
|||||||
pulse
|
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
|
/// 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
|
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
|
||||||
/// (suppressed while editing, where letters are text).
|
/// (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;
|
use sdl3::keyboard::Scancode as S;
|
||||||
if self.editing() {
|
if self.editing() {
|
||||||
if let Some(top) = self.stack.last_mut() {
|
if let Some(top) = self.stack.last_mut() {
|
||||||
@@ -439,6 +516,12 @@ impl Shell {
|
|||||||
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
|
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
|
||||||
S::PageUp if !repeat => MenuEvent::JumpBack,
|
S::PageUp if !repeat => MenuEvent::JumpBack,
|
||||||
S::PageDown if !repeat => MenuEvent::JumpForward,
|
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::Y if !repeat && !editing => MenuEvent::Secondary,
|
||||||
S::X if !repeat && !editing => MenuEvent::Tertiary,
|
S::X if !repeat && !editing => MenuEvent::Tertiary,
|
||||||
_ => return false,
|
_ => return false,
|
||||||
@@ -483,6 +566,9 @@ impl Shell {
|
|||||||
if let Some(text) = fx.toast {
|
if let Some(text) = fx.toast {
|
||||||
self.show_toast(text);
|
self.show_toast(text);
|
||||||
}
|
}
|
||||||
|
if let Some(text) = fx.copy {
|
||||||
|
self.actions.push_back(OverlayAction::CopyText(text));
|
||||||
|
}
|
||||||
if let Some(intent) = fx.connect {
|
if let Some(intent) = fx.connect {
|
||||||
self.start_connect(intent);
|
self.start_connect(intent);
|
||||||
}
|
}
|
||||||
@@ -497,6 +583,14 @@ impl Shell {
|
|||||||
self.stack.push(*screen);
|
self.stack.push(*screen);
|
||||||
self.motion = Motion::Push(Progress::new(TRANSITION_S));
|
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 => {
|
Nav::Pop => {
|
||||||
if self.stack.len() > 1 {
|
if self.stack.len() > 1 {
|
||||||
let leaving = self.stack.pop().expect("len > 1");
|
let leaving = self.stack.pop().expect("len > 1");
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ impl Shell {
|
|||||||
fonts,
|
fonts,
|
||||||
hints,
|
hints,
|
||||||
self.glyphs,
|
self.glyphs,
|
||||||
cx - probe.0 / 2.0,
|
cx - probe.size.0 / 2.0,
|
||||||
h - 34.0 * k,
|
h - 34.0 * k,
|
||||||
k,
|
k,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ impl Shell {
|
|||||||
// A modal card owns B/A while it's up — the screen's legend would lie.
|
// 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(),
|
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) {
|
match (&mut self.motion, motion_p) {
|
||||||
(Motion::Push(_), Some(raw)) => {
|
(Motion::Push(_), Some(raw)) => {
|
||||||
let p = ease_out_cubic(raw);
|
let p = ease_out_cubic(raw);
|
||||||
@@ -141,7 +146,7 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let n = self.stack.len();
|
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<'_> {
|
impl LayerEnv<'_> {
|
||||||
/// One screen composited as a unit: `alpha` fade, `dy` vertical slide, `scale`
|
/// 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,
|
/// about the screen center — its pinned title and hint bar ride inside the layer,
|
||||||
/// so chrome travels with content through a transition.
|
/// so chrome travels with content through a transition. Returns the hint bar's hit
|
||||||
fn paint(&mut self, screen: &mut Screen, alpha: f64, dy: f64, scale: f64) {
|
/// 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;
|
let canvas = self.canvas;
|
||||||
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
|
||||||
canvas.translate((0.0, dy as f32));
|
canvas.translate((0.0, dy as f32));
|
||||||
@@ -234,7 +246,7 @@ impl LayerEnv<'_> {
|
|||||||
self.w * 0.7,
|
self.w * 0.7,
|
||||||
);
|
);
|
||||||
screen.render(canvas, self.content, self.k, self.dt, self.fonts, &mut ctx);
|
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);
|
let hints = screen.hints(&ctx);
|
||||||
hint_bar(
|
hint_bar(
|
||||||
canvas,
|
canvas,
|
||||||
@@ -244,8 +256,12 @@ impl LayerEnv<'_> {
|
|||||||
18.0 * self.k,
|
18.0 * self.k,
|
||||||
self.h - 18.0 * self.k,
|
self.h - 18.0 * self.k,
|
||||||
self.k,
|
self.k,
|
||||||
);
|
)
|
||||||
}
|
.rects
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
canvas.restore();
|
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
|
/// 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
|
/// 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.
|
/// 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]
|
#[test]
|
||||||
fn every_settings_tab_rasters() {
|
fn every_settings_tab_rasters() {
|
||||||
let fonts = crate::theme::build_fonts().unwrap();
|
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())]);
|
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||||
dump(&mut s, 40, 8, "01-home", true);
|
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
|
// Mid-push into Settings (the transition still): a couple of fast frames land
|
||||||
// the capture around p ≈ 0.4 — both layers visible.
|
// the capture around p ≈ 0.4 — both layers visible.
|
||||||
s.handle_menu(MenuEvent::Tertiary);
|
s.handle_menu(MenuEvent::Tertiary);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! OSD, capture hint, the auto-fading start banner).
|
//! OSD, capture hint, the auto-fading start banner).
|
||||||
|
|
||||||
use crate::model::{ConsoleBus, ConsoleShared, HostRow};
|
use crate::model::{ConsoleBus, ConsoleShared, HostRow};
|
||||||
|
use crate::pointer::{Pointer, PointerKind};
|
||||||
use crate::screens::Screen;
|
use crate::screens::Screen;
|
||||||
use crate::shell::{ConsoleOptions, Shell};
|
use crate::shell::{ConsoleOptions, Shell};
|
||||||
use crate::theme::{match_first_family, Fonts};
|
use crate::theme::{match_first_family, Fonts};
|
||||||
@@ -16,7 +17,8 @@ use ash::vk as avk;
|
|||||||
use ash::vk::Handle as _;
|
use ash::vk::Handle as _;
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
use pf_presenter::overlay::{
|
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::vk as skvk;
|
||||||
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
|
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) {
|
if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
shell.key(*sc, *repeat)
|
let shift = keymod.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD);
|
||||||
|
shell.key(*sc, shift, *repeat)
|
||||||
}
|
}
|
||||||
sdl3::event::Event::TextInput { text, .. } => {
|
sdl3::event::Event::TextInput { text, .. } => {
|
||||||
shell.text_input(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> {
|
fn take_action(&mut self) -> Option<OverlayAction> {
|
||||||
self.shell.as_mut().and_then(|s| s.take_action())
|
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::anim::{approach, Spring, TRAY_C, TRAY_K};
|
||||||
use crate::library::{BUMP_C, BUMP_K};
|
use crate::library::{BUMP_C, BUMP_K};
|
||||||
|
use crate::pointer::{Pointer, PointerKind};
|
||||||
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
use crate::theme::{accent, fg, Fonts, PanelStroke, W};
|
||||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||||
use skia_safe::{Canvas, Paint, Path, RRect, Rect};
|
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
|
/// Next render, seat the scroll and the focus ease instantly instead of chasing — see
|
||||||
/// [`MenuList::jump_to`].
|
/// [`MenuList::jump_to`].
|
||||||
snap: bool,
|
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 {
|
impl MenuList {
|
||||||
@@ -97,6 +102,7 @@ impl MenuList {
|
|||||||
scroll: 0.0,
|
scroll: 0.0,
|
||||||
focus: Vec::new(),
|
focus: Vec::new(),
|
||||||
snap: true,
|
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> {
|
fn step(&mut self, delta: i32, len: usize) -> Option<MenuPulse> {
|
||||||
let target = self.cursor as i32 + delta;
|
let target = self.cursor as i32 + delta;
|
||||||
if len == 0 || target < 0 || target >= len as i32 {
|
if len == 0 || target < 0 || target >= len as i32 {
|
||||||
@@ -192,6 +225,8 @@ impl MenuList {
|
|||||||
|
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.clip_rect(rect, None, true);
|
canvas.clip_rect(rect, None, true);
|
||||||
|
self.geom.clear();
|
||||||
|
self.geom.resize(rows.len(), Rect::new_empty());
|
||||||
for (i, row) in rows.iter().enumerate() {
|
for (i, row) in rows.iter().enumerate() {
|
||||||
let f = self.focus[i];
|
let f = self.focus[i];
|
||||||
let top = f64::from(rect.top) + tops[i] * k - self.scroll + self.bump.pos * k;
|
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.scale((scale as f32, scale as f32));
|
||||||
canvas.translate((-cx as f32, -cy 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);
|
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 {
|
let stroke = if row.caret {
|
||||||
PanelStroke::Brand(0.7)
|
PanelStroke::Brand(0.7)
|
||||||
} else {
|
} else {
|
||||||
@@ -309,11 +347,31 @@ pub(crate) struct TabStrip {
|
|||||||
/// Chased highlight geometry `(x, width)` in device px. `None` until the first render,
|
/// 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.
|
/// so a freshly opened screen doesn't animate its highlight in from x = 0.
|
||||||
indicator: Option<(f64, f64)>,
|
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 {
|
impl TabStrip {
|
||||||
pub(crate) fn new() -> 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
|
/// 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;
|
let baseline = top + pill_h / 2.0 + size * 0.36;
|
||||||
|
self.pills.clear();
|
||||||
for (i, label) in labels.iter().enumerate() {
|
for (i, label) in labels.iter().enumerate() {
|
||||||
// Fade each label toward white by how much the highlight actually covers it, so
|
// 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.
|
// the two labels a sliding highlight passes between light up together.
|
||||||
let pill_x = x;
|
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 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 covered = (overlap / widths[i]).clamp(0.0, 1.0) as f32;
|
||||||
let tw = f64::from(fonts.measure(label, W::SemiBold, size));
|
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 slide-in (0 hidden → 1 seated), the Swift `.spring(0.32, 0.86)`.
|
||||||
tray: Spring,
|
tray: Spring,
|
||||||
key_flash: f64,
|
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 {
|
impl Keyboard {
|
||||||
@@ -488,9 +558,47 @@ impl Keyboard {
|
|||||||
col: 0,
|
col: 0,
|
||||||
tray: Spring::rest(0.0),
|
tray: Spring::rest(0.0),
|
||||||
key_flash: 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
|
/// 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).
|
/// checks included — a refusal comes back as a boundary pulse from the screen).
|
||||||
pub(crate) fn menu(&mut self, ev: MenuEvent) -> (KeyMsg, Option<MenuPulse>) {
|
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.
|
/// `seat` (0..1). The caller clips nothing — the tray rises from below the screen.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn render(
|
pub(crate) fn render(
|
||||||
&self,
|
&mut self,
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
fonts: &Fonts,
|
fonts: &Fonts,
|
||||||
w: f64,
|
w: f64,
|
||||||
@@ -568,6 +676,7 @@ impl Keyboard {
|
|||||||
k: f64,
|
k: f64,
|
||||||
) {
|
) {
|
||||||
let rows = key_rows();
|
let rows = key_rows();
|
||||||
|
self.keys.clear();
|
||||||
let tray_w = (560.0 * k).min(w - 32.0 * k);
|
let tray_w = (560.0 * k).min(w - 32.0 * k);
|
||||||
let tray_h = Self::tray_height() * k;
|
let tray_h = Self::tray_height() * k;
|
||||||
let x0 = (w - tray_w) / 2.0;
|
let x0 = (w - tray_w) / 2.0;
|
||||||
@@ -593,6 +702,7 @@ impl Keyboard {
|
|||||||
let x = x0 + pad + c as f64 * (key_w + gap);
|
let x = x0 + pad + c as f64 * (key_w + gap);
|
||||||
let focused = r == self.row && c == self.col;
|
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);
|
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 fill = if focused {
|
||||||
let mut b = accent(1.0);
|
let mut b = accent(1.0);
|
||||||
if self.key_flash > 0.02 {
|
if self.key_flash > 0.02 {
|
||||||
|
|||||||
@@ -102,6 +102,53 @@ pub enum OverlayAction {
|
|||||||
CancelConnect,
|
CancelConnect,
|
||||||
/// Quit the launcher (B at the root) — ends the process, Gaming Mode returns.
|
/// Quit the launcher (B at the root) — ends the process, Gaming Mode returns.
|
||||||
Quit,
|
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
|
/// Session lifecycle notifications into the overlay (browse mode drives its scenes off
|
||||||
@@ -142,6 +189,17 @@ pub trait Overlay {
|
|||||||
None
|
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
|
/// Drain one pending action raised by handled input. Called once per loop
|
||||||
/// iteration; return `None` when idle.
|
/// iteration; return `None` when idle.
|
||||||
fn take_action(&mut self) -> Option<OverlayAction> {
|
fn take_action(&mut self) -> Option<OverlayAction> {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
//! D disconnect, S stats tier, V microphone mute.
|
//! D disconnect, S stats tier, V microphone mute.
|
||||||
|
|
||||||
use crate::input::{Capture, FingerPhase};
|
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::{
|
use crate::present_pace::{
|
||||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
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) {
|
if o.handle_event(&event) {
|
||||||
continue;
|
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 {
|
match event {
|
||||||
Event::Quit { .. } => {
|
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 => {
|
action => {
|
||||||
let force_software = Arc::new(AtomicBool::new(false));
|
let force_software = Arc::new(AtomicBool::new(false));
|
||||||
match on_action(
|
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)?
|
/// 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
|
/// 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.
|
/// forwarded as touch passthrough. An unknown/invalid id (INVALID) reads as not-direct.
|
||||||
|
|||||||
Reference in New Issue
Block a user