apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m3s
android / android (push) Canceled after 1m27s
apple / screenshots (push) Canceled after 1m39s
arch / build-publish (push) Canceled after 1m29s
ci / rust (push) Canceled after 1m29s
ci / bench (push) Canceled after 1m17s
deb / build-publish (push) Canceled after 1m15s
deb / build-publish-host (push) Canceled after 27s
decky / build-publish (push) Canceled after 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
flatpak / build-publish (push) Canceled after 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 17s
Parity with the Apple client (35c2c145): the SDL/Linux presenter built the
host-forwarded cursor as an OS cursor at its native bitmap size, so the
on-screen pointer tracked the HOST's display scaling — a 4K/high-DPI virtual
display forwards a 96 px bitmap that then rendered huge on the client.
SDL cursors are fixed-size from their surface (no draw-time scaling), so
cache the shapes RAW and resample each by the live video-fit factor
(min(window_px / mode) — the same aspect-fit `finger_to_content` uses) at
install time, rebuilding when the serial OR the fit changes (a resize). The
resample area-averages on premultiplied alpha so a transparent pixel's
colour can't bleed into the fringe. The pointer now stays sized to the
streamed desktop at any host scaling.
Not yet eyeballed on a Deck/Linux box — SDL's HiDPI cursor sizing is
version-dependent; validate on glass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
10 KiB
Rust
233 lines
10 KiB
Rust
//! Client-side cursor rendering (design/remote-desktop-sweep.md M2): the host forwards the
|
||
//! pointer's SHAPE (reliable control stream, cached by serial) and per-frame STATE (lossy
|
||
//! `0xD0` — position/visibility), and WE draw it as a real OS cursor — pointer feel stops
|
||
//! paying the video round-trip (the Parsec/RDP model). Active only when the session
|
||
//! negotiated it (`HOST_CAP_CURSOR` in the Welcome — the host stopped compositing then) and
|
||
//! only applied while the DESKTOP mouse model is engaged: under capture the pointer is
|
||
//! relative-locked (SDL hides it) and games draw their own cursor in-frame.
|
||
//!
|
||
//! The host sends the bitmap in host-FRAMEBUFFER pixels, whose size tracks the host virtual
|
||
//! display's DPI scaling (32 px at 100%, 96 px at 300%). Drawn 1:1 it balloons on a high-DPI
|
||
//! host; instead we scale it by the SAME aspect-fit factor the video is drawn at
|
||
//! (`min(window_px/mode)`), so the pointer stays sized to the streamed desktop at any host
|
||
//! scaling. SDL cursors are fixed-size from their surface (no draw-time scaling), so we cache
|
||
//! shapes RAW and resample per install — rebuilding when the serial OR the fit changes.
|
||
|
||
use punktfunk_core::client::NativeClient;
|
||
use punktfunk_core::quic::{CursorState, HOST_CAP_CURSOR};
|
||
use sdl3::mouse::{Cursor, MouseUtil, SystemCursor};
|
||
use sdl3::pixels::PixelFormat;
|
||
use sdl3::surface::Surface;
|
||
use std::collections::HashMap;
|
||
use std::time::Duration;
|
||
|
||
/// Shape serials cached at most — cursors cycle through a handful of shapes (arrow, I-beam,
|
||
/// resize…); a runaway host can't grow the map past this (the cache resets, shapes re-arrive
|
||
/// on the reliable stream via the serial-miss path).
|
||
const SHAPE_CACHE_MAX: usize = 64;
|
||
|
||
/// A forwarded cursor shape held RAW (host-framebuffer-pixel bytes + hotspot), so it can be
|
||
/// rebuilt into a scaled OS cursor whenever the video-fit changes (a window resize). Caching a
|
||
/// fixed-size `Cursor` instead would freeze the pointer at its build-time size.
|
||
struct RawShape {
|
||
rgba: Vec<u8>,
|
||
w: u32,
|
||
h: u32,
|
||
hot_x: u32,
|
||
hot_y: u32,
|
||
}
|
||
|
||
pub struct CursorChannel {
|
||
/// The Welcome carried `HOST_CAP_CURSOR` — the host forwards instead of compositing.
|
||
negotiated: bool,
|
||
/// Serial → raw forwarded shape (bounded by [`SHAPE_CACHE_MAX`]).
|
||
shapes: HashMap<u32, RawShape>,
|
||
/// The serial + fit scale the currently-installed OS cursor was built at (`None` =
|
||
/// default/system cursor). A change in EITHER forces a rebuild.
|
||
installed: Option<(u32, f32)>,
|
||
/// Keeps the installed `Cursor` alive — SDL requires it to outlive its `set()`.
|
||
installed_cursor: Option<Cursor>,
|
||
/// Latest `0xD0` state (latest-wins across a drained batch).
|
||
state: Option<CursorState>,
|
||
}
|
||
|
||
impl CursorChannel {
|
||
pub fn new(connector: &NativeClient) -> CursorChannel {
|
||
let negotiated = connector.host_caps() & HOST_CAP_CURSOR != 0;
|
||
if negotiated {
|
||
tracing::info!("cursor channel negotiated — host cursor renders locally");
|
||
}
|
||
CursorChannel {
|
||
negotiated,
|
||
shapes: HashMap::new(),
|
||
installed: None,
|
||
installed_cursor: None,
|
||
state: None,
|
||
}
|
||
}
|
||
|
||
/// Whether the host forwards the cursor this session (it no longer composites one).
|
||
pub fn negotiated(&self) -> bool {
|
||
self.negotiated
|
||
}
|
||
|
||
/// The latest drained `0xD0` state — the run loop reads `relative_hint` off it for the
|
||
/// M3 host-driven mode flip (and `x`/`y` as the reappear position when leaving relative).
|
||
pub fn state(&self) -> Option<CursorState> {
|
||
self.state
|
||
}
|
||
|
||
/// Drain the two planes and apply the newest state — once per run-loop iteration.
|
||
/// `desktop_active` = the desktop mouse model is engaged (captured + desktop): only then
|
||
/// do we own the local cursor's shape/visibility; under capture SDL's relative mode owns
|
||
/// it, and released the system cursor must look normal. `fit_scale` is host-framebuffer
|
||
/// pixels → window pixels (the aspect-fit factor the video is drawn at); the shape is
|
||
/// resampled by it so the pointer matches the streamed desktop at any host DPI.
|
||
pub fn pump(
|
||
&mut self,
|
||
connector: &NativeClient,
|
||
mouse: &MouseUtil,
|
||
desktop_active: bool,
|
||
fit_scale: f32,
|
||
) {
|
||
if !self.negotiated {
|
||
return;
|
||
}
|
||
while let Ok(shape) = connector.next_cursor_shape(Duration::ZERO) {
|
||
if self.shapes.len() >= SHAPE_CACHE_MAX {
|
||
// Degenerate host: reset — live shapes re-install via the serial-miss path.
|
||
self.shapes.clear();
|
||
self.installed = None;
|
||
}
|
||
let (w, h) = (shape.w as u32, shape.h as u32);
|
||
if w == 0 || h == 0 || shape.rgba.len() < (w * h * 4) as usize {
|
||
tracing::warn!(w, h, "cursor shape malformed — ignored");
|
||
continue;
|
||
}
|
||
// A re-sent serial replaces its entry; force re-install if it's current.
|
||
if matches!(self.installed, Some((s, _)) if s == shape.serial) {
|
||
self.installed = None;
|
||
}
|
||
self.shapes.insert(
|
||
shape.serial,
|
||
RawShape {
|
||
rgba: shape.rgba,
|
||
w,
|
||
h,
|
||
hot_x: shape.hot_x as u32,
|
||
hot_y: shape.hot_y as u32,
|
||
},
|
||
);
|
||
}
|
||
while let Ok(st) = connector.next_cursor_state(Duration::ZERO) {
|
||
self.state = Some(st); // latest wins
|
||
}
|
||
|
||
if !desktop_active {
|
||
// Capture mode / released: hand the cursor back to the system default so a
|
||
// released pointer over the window doesn't wear the host's shape.
|
||
if self.installed.take().is_some() {
|
||
if let Ok(c) = Cursor::from_system(SystemCursor::Arrow) {
|
||
c.set();
|
||
self.installed_cursor = Some(c); // keep it alive past set()
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let Some(st) = self.state else { return };
|
||
if st.visible() && self.installed != Some((st.serial, fit_scale)) {
|
||
if let Some(shape) = self.shapes.get(&st.serial) {
|
||
match build_scaled_cursor(shape, fit_scale) {
|
||
Ok(cursor) => {
|
||
cursor.set();
|
||
self.installed = Some((st.serial, fit_scale));
|
||
self.installed_cursor = Some(cursor); // outlive set()
|
||
}
|
||
Err(e) => tracing::warn!(error = %e, w = shape.w, h = shape.h,
|
||
"cursor shape rejected by SDL — keeping the previous cursor"),
|
||
}
|
||
}
|
||
// Serial miss: the (reliable) shape hasn't landed yet — keep the previous
|
||
// cursor for the RTT rather than flashing default.
|
||
}
|
||
// Visibility follows the host (a host app hid its pointer ⇒ ours hides too). Queried,
|
||
// not shadowed, so apply_capture's own show/hide calls can never desync us.
|
||
if mouse.is_cursor_showing() != st.visible() {
|
||
mouse.show_cursor(st.visible());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resample a raw shape by `fit_scale` and build an SDL color cursor from it. The hotspot scales
|
||
/// with the bitmap so the click point stays true. `fit_scale <= 0` (or a degenerate result) is
|
||
/// clamped so we always hand SDL a ≥1×1 surface.
|
||
fn build_scaled_cursor(shape: &RawShape, fit_scale: f32) -> Result<Cursor, String> {
|
||
let scale = if fit_scale.is_finite() && fit_scale > 0.0 {
|
||
fit_scale
|
||
} else {
|
||
1.0
|
||
};
|
||
let dw = ((shape.w as f32 * scale).round() as u32).max(1);
|
||
let dh = ((shape.h as f32 * scale).round() as u32).max(1);
|
||
let hot_x = ((shape.hot_x as f32 * scale).round() as u32).min(dw - 1) as i32;
|
||
let hot_y = ((shape.hot_y as f32 * scale).round() as u32).min(dh - 1) as i32;
|
||
|
||
if dw == shape.w && dh == shape.h {
|
||
// 1:1 fit (mode == window) — no resample, feed the bytes straight through.
|
||
let mut data = shape.rgba.clone();
|
||
let surf = Surface::from_data(
|
||
&mut data,
|
||
shape.w,
|
||
shape.h,
|
||
shape.w * 4,
|
||
PixelFormat::RGBA32,
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
return Cursor::from_surface(&surf, hot_x, hot_y).map_err(|e| e.to_string());
|
||
}
|
||
|
||
let mut scaled = resample_rgba(&shape.rgba, shape.w, shape.h, dw, dh);
|
||
let surf = Surface::from_data(&mut scaled, dw, dh, dw * 4, PixelFormat::RGBA32)
|
||
.map_err(|e| e.to_string())?;
|
||
Cursor::from_surface(&surf, hot_x, hot_y).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Area-average resample of a straight-alpha RGBA bitmap `(sw×sh) → (dw×dh)`. Averaging is done on
|
||
/// PREMULTIPLIED colour (weighting each source texel by its alpha) so transparent-pixel colour
|
||
/// can't bleed into the fringe, then un-premultiplied back to straight alpha. Handles both down-
|
||
/// and up-scale; for a cursor the common case is a downscale (high-DPI host → smaller pointer).
|
||
fn resample_rgba(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
|
||
let mut out = vec![0u8; (dw * dh * 4) as usize];
|
||
let fx = sw as f32 / dw as f32;
|
||
let fy = sh as f32 / dh as f32;
|
||
for dy in 0..dh {
|
||
let sy0 = (dy as f32 * fy).floor() as u32;
|
||
let sy1 = (((dy + 1) as f32 * fy).ceil() as u32).clamp(sy0 + 1, sh);
|
||
for dx in 0..dw {
|
||
let sx0 = (dx as f32 * fx).floor() as u32;
|
||
let sx1 = (((dx + 1) as f32 * fx).ceil() as u32).clamp(sx0 + 1, sw);
|
||
let (mut r, mut g, mut b, mut a_sum, mut n) = (0f32, 0f32, 0f32, 0f32, 0f32);
|
||
for sy in sy0..sy1 {
|
||
for sx in sx0..sx1 {
|
||
let i = ((sy * sw + sx) * 4) as usize;
|
||
let a = src[i + 3] as f32 / 255.0;
|
||
r += src[i] as f32 * a;
|
||
g += src[i + 1] as f32 * a;
|
||
b += src[i + 2] as f32 * a;
|
||
a_sum += a;
|
||
n += 1.0;
|
||
}
|
||
}
|
||
let di = ((dy * dw + dx) * 4) as usize;
|
||
if a_sum > 0.0 {
|
||
out[di] = (r / a_sum).round().clamp(0.0, 255.0) as u8;
|
||
out[di + 1] = (g / a_sum).round().clamp(0.0, 255.0) as u8;
|
||
out[di + 2] = (b / a_sum).round().clamp(0.0, 255.0) as u8;
|
||
out[di + 3] = (a_sum / n * 255.0).round().clamp(0.0, 255.0) as u8;
|
||
}
|
||
// else fully transparent — already zero-filled.
|
||
}
|
||
}
|
||
out
|
||
}
|