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.
226 lines
11 KiB
Rust
226 lines
11 KiB
Rust
//! The presenter↔console-UI contract (punktfunk-planning
|
||
//! `linux-client-rearchitecture.md` §6.1): the presenter exposes its device and
|
||
//! composites at most ONE sampled RGBA quad per frame; the overlay implementation
|
||
//! (pf-console-ui, Skia) fills offscreen images on its own damage-driven schedule. No
|
||
//! Skia type crosses this line — everything here is ash — and a `frame()` returning
|
||
//! `None` costs the hot path nothing (the quad isn't even recorded).
|
||
|
||
use ash::vk;
|
||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||
use punktfunk_core::config::GamepadPref;
|
||
|
||
/// The presenter's device, shared with the overlay so its renderer (Skia's
|
||
/// `DirectContext`) creates resources on the same VkDevice/queue. Handles stay valid for
|
||
/// the presenter's lifetime — the overlay must be dropped before it (the run loop owns
|
||
/// both and drops the overlay first).
|
||
pub struct SharedDevice {
|
||
pub entry: ash::Entry,
|
||
pub instance: ash::Instance,
|
||
pub physical_device: vk::PhysicalDevice,
|
||
pub device: ash::Device,
|
||
pub queue: vk::Queue,
|
||
pub queue_family_index: u32,
|
||
/// External-sync lock for `queue` — the decode lane submits to the same queue
|
||
/// from the pump thread, so every overlay flush/submit must hold it. The presenter,
|
||
/// this overlay and the native decode lane all serialize on this one lock; take it
|
||
/// with [`pf_client_core::video::QueueLock::guard`], whose RAII form is what every
|
||
/// Rust caller wants.
|
||
pub queue_lock: std::sync::Arc<pf_client_core::video::QueueLock>,
|
||
}
|
||
|
||
/// What the overlay may draw this frame — composed by the run loop from session state.
|
||
/// Milestone 1 (OSD/HUD) is text-shaped; the console library replaces this with a
|
||
/// richer scene enum when it moves in.
|
||
pub struct FrameCtx<'a> {
|
||
/// Swapchain size in pixels — the overlay renders 1:1.
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// UI scale for the stream chrome: the window's display scale (DPI × the display's content
|
||
/// scale — `1.0` at 96 dpi / 100 %), times the `PUNKTFUNK_OSD_SCALE` preference. Because the
|
||
/// overlay renders in *physical* pixels, a fixed-pixel OSD shrinks as panel density rises —
|
||
/// unreadable at 14 px on a 4K laptop at 200 %. Every chrome metric is multiplied by this.
|
||
/// Sanitized and clamped by the run loop (`overlay_scale`), so it is always finite and > 0.
|
||
pub scale: f32,
|
||
/// Multi-line stats OSD (top-left panel); `None` = hidden.
|
||
pub stats: Option<&'a str>,
|
||
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
|
||
pub hint: Option<&'a str>,
|
||
/// The user muted their microphone mid-stream (Ctrl+Alt+Shift+V). Draws a persistent
|
||
/// badge, deliberately independent of the stats tier: a muted mic is a fact about what
|
||
/// the host is hearing, and "did my mute take?" must be answerable with the overlay off.
|
||
/// False whenever this session has no mic uplink at all — the badge never invents one.
|
||
pub mic_muted: bool,
|
||
/// A mid-stream Match-window resize is in flight (design/midstream-resolution-resize.md,
|
||
/// client UX): draw a full-screen scrim + spinner so the host's 0.3–2 s virtual-display
|
||
/// and encoder rebuild reads as an intentional pause rather than the stream stretching to
|
||
/// the changed window. Cleared the instant the sharp new-resolution frame is on glass.
|
||
pub resizing: bool,
|
||
/// The active gamepad's name (the console library's controller chip).
|
||
pub pad: Option<&'a str>,
|
||
/// The active pad's resolved kind — drives the console UI's button glyphs
|
||
/// (PlayStation shapes for DualSense/DualShock, ABXY letters otherwise).
|
||
pub pad_pref: Option<GamepadPref>,
|
||
/// Every connected pad (the console settings' "Use controller" row).
|
||
pub pads: &'a [pf_client_core::gamepad::PadInfo],
|
||
}
|
||
|
||
/// One overlay image ready to composite: RGBA, PREMULTIPLIED alpha, already in
|
||
/// `SHADER_READ_ONLY_OPTIMAL`, sized `width`×`height` (normally the `FrameCtx` size; a
|
||
/// stale size during a resize just stretches for a frame).
|
||
pub struct OverlayFrame {
|
||
pub image: vk::Image,
|
||
pub view: vk::ImageView,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
}
|
||
|
||
/// An action the overlay raises out of its input handling (browse mode). Only actions
|
||
/// the RUN LOOP must act on live here — starting/canceling sessions and quitting; data
|
||
/// work (pairing, discovery, library fetches…) rides the console command bus instead.
|
||
pub enum OverlayAction {
|
||
/// Start a session on this host. `launch` carries a library title id on the Hello
|
||
/// (`None` streams the desktop); `title` is display-only (window title).
|
||
Launch {
|
||
addr: String,
|
||
port: u16,
|
||
fp_hex: String,
|
||
launch: Option<String>,
|
||
title: String,
|
||
/// One-off settings-profile override for THIS launch (a profile id — a pinned
|
||
/// card's connect). `None` resolves the host's default binding as before; the
|
||
/// binary feeds it to `trust::effective_settings`, so a dangling id quietly
|
||
/// falls back to the defaults and never blocks the connect.
|
||
profile: Option<String>,
|
||
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
|
||
/// open a connect the host PARKS until the operator approves this device in its
|
||
/// console (a long connect budget), then persist it as paired. `false` = an
|
||
/// ordinary connect to an already-paired host.
|
||
request_access: bool,
|
||
},
|
||
/// Abort an in-flight connect (B while Connecting) — the console keeps browsing.
|
||
/// The run loop stops the pump; a dial that already won the race is quit-closed.
|
||
CancelConnect,
|
||
/// Quit the launcher (B at the root) — ends the process, Gaming Mode returns.
|
||
Quit,
|
||
/// Put this text on the system clipboard (the host menu's "Copy link"). An action
|
||
/// rather than a console command because the clipboard belongs to SDL, which lives on
|
||
/// the run loop's thread and nowhere else.
|
||
CopyText(String),
|
||
}
|
||
|
||
/// Which button a [`PointerInput`] press/release carries. A touchscreen contact always
|
||
/// arrives as `Primary` — there is no second finger-button, and the console's back
|
||
/// affordance is on glass.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum PointerButton {
|
||
Primary,
|
||
/// The right button — the console reads it as Back, the pointer's B.
|
||
Secondary,
|
||
}
|
||
|
||
/// Pointer or touch input offered to the overlay, in SWAPCHAIN PIXELS.
|
||
///
|
||
/// Pixels, not window coordinates, because that is the space the overlay renders in: a
|
||
/// screen hit-tests the very rects it drew last frame instead of re-deriving a layout
|
||
/// through the display scale. The run loop owns the conversion — it is the side that
|
||
/// holds the window.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum PointerInput {
|
||
Move {
|
||
x: f32,
|
||
y: f32,
|
||
},
|
||
Down {
|
||
x: f32,
|
||
y: f32,
|
||
button: PointerButton,
|
||
},
|
||
Up {
|
||
x: f32,
|
||
y: f32,
|
||
button: PointerButton,
|
||
},
|
||
/// One wheel/trackpad scroll step at `x`/`y`; `dy` > 0 scrolls away from the user.
|
||
Wheel {
|
||
x: f32,
|
||
y: f32,
|
||
dy: f32,
|
||
},
|
||
/// The gesture was abandoned (the pointer left the window, the touch was canceled) —
|
||
/// any armed press is dropped without acting.
|
||
Cancel,
|
||
}
|
||
|
||
/// Session lifecycle notifications into the overlay (browse mode drives its scenes off
|
||
/// these; the OSD/HUD ignore them).
|
||
pub enum SessionPhase<'a> {
|
||
/// A launch action was accepted — the connect is in flight.
|
||
Connecting,
|
||
/// Connected; frames are coming.
|
||
Streaming,
|
||
/// The connect failed (browse mode returns to the library with this message).
|
||
Failed(&'a str),
|
||
/// The session ran and ended (`Some` = abnormal reason for the status strip).
|
||
Ended(Option<&'a str>),
|
||
/// The session ended and the client is DIALING AGAIN by itself — today only because
|
||
/// the negotiated codec ran out of decode rungs (M8's software-HEVC drop) and the
|
||
/// retry advertises a codec this device can actually finish.
|
||
///
|
||
/// Distinct from [`Self::Ended`] and [`Self::Failed`] because the user's next action
|
||
/// is different: nothing. "Session ended — HEVC decoding failed" invites a manual
|
||
/// reconnect that is already in flight, and "Couldn't connect" is simply false — the
|
||
/// connect worked, the decode did not.
|
||
Reconnecting(&'a str),
|
||
}
|
||
|
||
/// The console-UI side. Object-safe; the session binary passes
|
||
/// `Option<Box<dyn Overlay>>` (None = the Skia-free power-user build).
|
||
pub trait Overlay {
|
||
/// One-time setup on the presenter's device.
|
||
fn init(&mut self, shared: &SharedDevice) -> anyhow::Result<()>;
|
||
|
||
/// Input routing, before capture sees the event. `true` = consumed (the library or
|
||
/// a menu is up) — the event must not reach capture/forwarding.
|
||
fn handle_event(&mut self, event: &sdl3::event::Event) -> bool;
|
||
|
||
/// Gamepad menu-mode navigation (browse mode; the run loop drains the service's
|
||
/// menu channel). Returns a haptic pulse to play on the menu pad, if any.
|
||
fn handle_menu(&mut self, _event: MenuEvent) -> Option<MenuPulse> {
|
||
None
|
||
}
|
||
|
||
/// Mouse/touch input, in swapchain pixels, before capture sees it. `true` = consumed
|
||
/// (the console is up and something under the pointer took it) — the event must not
|
||
/// reach capture/forwarding.
|
||
///
|
||
/// Separate from [`Self::handle_event`] because the window→pixel conversion belongs to
|
||
/// the run loop, which is the side that holds the window: the overlay renders in
|
||
/// pixels and would otherwise have to re-derive the display scale it never sees.
|
||
fn handle_pointer(&mut self, _input: PointerInput) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Drain one pending action raised by handled input. Called once per loop
|
||
/// iteration; return `None` when idle.
|
||
fn take_action(&mut self) -> Option<OverlayAction> {
|
||
None
|
||
}
|
||
|
||
/// A session lifecycle edge (browse mode scene driving).
|
||
fn session_phase(&mut self, _phase: SessionPhase) {}
|
||
|
||
/// True while a text field is being edited — the run loop starts/stops SDL text
|
||
/// input to match (IME + `Event::TextInput` delivery on desktop; under gamescope
|
||
/// this is also what lets Steam's on-screen keyboard type into the app).
|
||
fn text_input_active(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Once per presenter iteration. Damage-driven: re-render (flush + transition to
|
||
/// SHADER_READ_ONLY) only when the content or size changed, else return the previous
|
||
/// image. `None` = nothing to composite. The returned image must stay untouched
|
||
/// until `frame()` runs again (the presenter runs one frame in flight and the
|
||
/// implementation keeps a ring of two, so alternating satisfies this).
|
||
fn frame(&mut self, ctx: &FrameCtx) -> anyhow::Result<Option<OverlayFrame>>;
|
||
}
|