feat(console): the console answers a mouse and a finger, and host cards get a menu

Two gaps, both found on the shared Linux/Windows console UI.

**The settings tabs only moved for a gamepad.** They were bound to the shoulder
buttons and to PgUp/PgDn, and the legend spells PgUp/PgDn out only when NO pad is
attached — so with a controller plugged in a keyboard user had nothing to find,
and a mouse or a touchscreen could not change section at all.

The root cause was wider than the strip: `SkiaOverlay::handle_event` matched only
`KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell
past the console into the run loop, which routes pointer input exclusively at
`stream.capture` — `None` while you are browsing. Nothing in the console had ever
been clickable. Making just the pills answer would not have helped either: the
settings screen is opened with X from home, so a mouse could not reach it.

So the console gets a real pointer path:

- `Overlay::handle_pointer` carries mouse/touch in SWAPCHAIN PIXELS. The run loop
  converts (it owns the window, hence the display scale, and mouse coordinates are
  logical while fingers are normalised); the console then hit-tests the very rects
  it drew last frame. Only DIRECT touch devices are offered — an indirect trackpad
  already drives the mouse.
- Widgets act on the PRESS, not the release. The list and both carousels scroll the
  focused item toward the centre, so what you pressed has slid out from under your
  finger by the time it lifts; press-to-act has no such race and there is no drag
  gesture to compete with.
- The hint bar became the pointer's button bar. It is already the console's only
  on-screen statement of what the face buttons do, and a pointer has none — so its
  Confirm/Back/Secondary/Tertiary pills are clickable on every screen, which is what
  puts Settings and Library within reach of a mouse at all.
- Tab / Shift+Tab change section; PgUp/PgDn still do, and the keyboard legend now
  reads "Tab".
- Right-click is Back everywhere, EXCEPT at the root: B there quits the launcher and
  a right-click is far easier to fire by accident. Quitting stays explicit.

**Host cards had no menu.** Every other client hangs Wake / Copy link / Edit /
Forget off a host card; the console could add a host and connect to one, and that
was all — so a renamed machine or a fat-fingered address stayed wrong forever
unless you opened a desktop shell. UP on a saved tile now opens that host's menu,
the same gesture the Android console uses, on the one direction a horizontal
carousel leaves free.

- `ConsoleCmd::UpdateHost` edits the stored host IN PLACE. Removing and re-adding
  would silently drop the fingerprint, the learned MAC, the pinned cards and the
  profile binding — that is a rename, not a re-pair.
- `ConsoleCmd::ForgetHost` drops it; if it is still advertising it returns as a
  discovered, unpaired row, which is the honest state.
- Forget arms on the first press and fires on the second. The other clients forget
  outright; a console is driven by a thumbstick from across a room.
- A pinned profile card offers only Unpin. It is a shortcut, not a second host, and
  offering to forget the host from it would blur exactly the distinction a pin draws.
- "Edit…" REPLACES the menu on the stack rather than stacking over it, so Back from
  the editor doesn't land on a menu describing the host as it was before the edit.

Verified in the pf-lxcheck2 container (this crate compiles to nothing on macOS —
a bare `cargo check` there is vacuous): plain build and `clippy --all-targets`
clean under `-D warnings`, 72 tests pass. Seven are new, and cover the reported
bug directly — a press on a pill selects that tab, and each tab still keeps its
own cursor when a pointer is what switched it.
This commit is contained in:
2026-08-07 12:36:22 +02:00
parent c6b183450a
commit f06b3d9d04
21 changed files with 1643 additions and 48 deletions
+100
View File
@@ -0,0 +1,100 @@
//! Pointer and touch input inside the console.
//!
//! The console is a focus UI: a pad moves a cursor and presses A. A pointer brings its
//! own cursor, so every widget resolves a press directly onto whatever is under it and
//! **acts on the press**, not on the release.
//!
//! That is deliberate, not a shortcut. Both the menu list and the two carousels scroll
//! the FOCUSED item toward the centre of the screen, so the thing you pressed has already
//! slid out from under your finger by the time it lifts. A click-on-release rule would
//! have to chase it, and on a touchscreen — where the finger doesn't move but the content
//! does — it would routinely land on the wrong row. Press-to-act has no such race, and
//! the console has no drag gesture for it to compete with.
//!
//! Coordinates are device pixels: the run loop converts (it owns the window and therefore
//! the display scale), and a widget hit-tests the very rect it drew last frame.
use skia_safe::Rect;
/// A pointer/touch interaction, in device pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Pointer {
pub x: f64,
pub y: f64,
pub kind: PointerKind,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PointerKind {
/// The primary button went down, or a finger touched the glass — the acting edge.
Press,
/// The primary button or finger came up. Widgets ignore it today; it is carried so a
/// later drag gesture has an edge to close on.
Release,
/// Motion, with or without a button held.
Move,
/// The gesture was abandoned (the pointer left the window).
Cancel,
/// One scroll step; `up` = away from the user.
Scroll { up: bool },
/// The secondary (right) button went down — the pointer's B. Handled by the shell for
/// every screen at once, so no screen has to remember to offer a way back.
Back,
}
impl Pointer {
/// Is this the edge widgets act on?
pub(crate) fn press(&self) -> bool {
self.kind == PointerKind::Press
}
/// Inside `rect`? Half-open, so neighbouring rects can share an edge without both
/// claiming the same pixel. An EMPTY rect never hits — which is what lets a list
/// record `Rect::new_empty()` for rows it culled and keep its indices aligned.
pub(crate) fn hits(&self, rect: Rect) -> bool {
let (x, y) = (self.x as f32, self.y as f32);
x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom
}
/// The index of the first rect under the pointer.
pub(crate) fn pick(&self, rects: &[Rect]) -> Option<usize> {
rects.iter().position(|r| self.hits(*r))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(x: f64, y: f64) -> Pointer {
Pointer {
x,
y,
kind: PointerKind::Press,
}
}
#[test]
fn hit_testing_is_half_open_and_skips_empty_rects() {
let r = Rect::from_xywh(10.0, 10.0, 20.0, 20.0);
assert!(at(10.0, 10.0).hits(r), "the top-left corner is inside");
assert!(
!at(30.0, 20.0).hits(r),
"the right edge belongs to the next"
);
assert!(!at(9.0, 20.0).hits(r));
// A culled row's placeholder must never swallow a press.
assert!(!at(0.0, 0.0).hits(Rect::new_empty()));
}
#[test]
fn pick_returns_the_first_match() {
let rects = [
Rect::new_empty(),
Rect::from_xywh(0.0, 0.0, 10.0, 10.0),
Rect::from_xywh(0.0, 0.0, 10.0, 10.0),
];
assert_eq!(at(5.0, 5.0).pick(&rects), Some(1));
assert_eq!(at(50.0, 5.0).pick(&rects), None);
}
}