feat(inject/windows): pen P3 — PT_PEN + PT_TOUCH synthetic pointer injection
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 54s
release / apple (push) Successful in 9m40s
arch / build-publish (push) Successful in 12m4s
ci / bench (push) Successful in 7m5s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
apple / screenshots (push) Successful in 6m50s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
android / android (push) Successful in 14m47s
ci / rust (push) Canceled after 13m52s
deb / build-publish (push) Canceled after 7m26s
deb / build-publish-host (push) Canceled after 5m38s
flatpak / build-publish (push) Canceled after 1m55s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m59s
windows-host / package (push) Canceled after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
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
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 54s
release / apple (push) Successful in 9m40s
arch / build-publish (push) Successful in 12m4s
ci / bench (push) Successful in 7m5s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
apple / screenshots (push) Successful in 6m50s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
android / android (push) Successful in 14m47s
ci / rust (push) Canceled after 13m52s
deb / build-publish (push) Canceled after 7m26s
deb / build-publish-host (push) Canceled after 5m38s
flatpak / build-publish (push) Canceled after 1m55s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m59s
windows-host / package (push) Canceled after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
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
The Windows leg of design/pen-tablet-input.md §6, following Apollo's recipe: a per-session PT_PEN device (pressure rescaled 0..1024, polar tilt→tiltX/Y, barrel roll on rotation — Windows Ink renders Pencil Pro roll natively, barrel button, eraser via INVERTED/ERASER flags, hover) with a 40ms refresh thread against the ~100ms synthetic-pointer staleness auto-lift, plus a PT_TOUCH device closing the historical SendInput wire-touch no-op (full active-contact frames, per-id DOWN/UP edges, self-healing lost-DOWN synthesis). pen_supported now probes PT_PEN creation on Windows (1809+), which lights up HOST_CAP_PEN and the GameStream featureFlags there — Moonlight iPad + Pencil can ink on a Windows host. Frame grouping mirrors the Linux uinput backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,5 +61,10 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
# Synthetic pointer devices (PT_PEN / PT_TOUCH) — the pen/touch injectors. The device
|
||||
# create/destroy + POINTER_TYPE_INFO live under Controls in this metadata layout;
|
||||
# InjectSyntheticPointerInput + POINTER_*_INFO under Input_Pointer.
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Input_Pointer",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
//! Windows synthetic-pointer injection (design/pen-tablet-input.md §6): a per-session `PT_PEN`
|
||||
//! device carrying the pen plane's full fidelity — pressure (rescaled to Windows' 0..1024),
|
||||
//! polar tilt → tiltX/tiltY, barrel roll on `rotation` (0..359 — Windows Ink renders Pencil
|
||||
//! Pro roll natively), barrel button, eraser (`PEN_FLAG_INVERTED`/`ERASER`), hover — plus a
|
||||
//! `PT_TOUCH` device that closes the long-standing SendInput touch no-op for wire touches.
|
||||
//!
|
||||
//! Both follow Apollo's proven recipe (`design/apollo-comparison.md`): synthetic pointer state
|
||||
//! goes STALE if not re-injected (~100 ms), so each device runs a small refresh thread that
|
||||
//! re-asserts the last frame every [`REFRESH_MS`] while the pen is in range / contacts are
|
||||
//! held — a stationary stylus must not hover-out (and a held finger must not auto-lift) just
|
||||
//! because no new samples arrived. `CreateSyntheticPointerDevice` needs Win10 1809+;
|
||||
//! [`crate::pen_supported`] probes it, so older hosts simply never advertise pen.
|
||||
//!
|
||||
//! Frame grouping mirrors the Linux uinput backend: a proximity-enter is injected together
|
||||
//! with its position (never at a stale point), tip edges get their own DOWN/UP frames, and a
|
||||
//! range-leave is a final frame without `INRANGE`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{
|
||||
PenSample, PenTool, PenTransition, PEN_ANGLE_UNKNOWN, PEN_BARREL1, PEN_TILT_UNKNOWN,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::Controls::{
|
||||
CreateSyntheticPointerDevice, DestroySyntheticPointerDevice, HSYNTHETICPOINTERDEVICE,
|
||||
POINTER_FEEDBACK_DEFAULT, POINTER_TYPE_INFO, POINTER_TYPE_INFO_0,
|
||||
};
|
||||
use windows::Win32::UI::Input::Pointer::{
|
||||
InjectSyntheticPointerInput, POINTER_FLAGS, POINTER_FLAG_DOWN, POINTER_FLAG_FIRSTBUTTON,
|
||||
POINTER_FLAG_INCONTACT, POINTER_FLAG_INRANGE, POINTER_FLAG_NEW, POINTER_FLAG_UP,
|
||||
POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE,
|
||||
PEN_MASK_PRESSURE, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH,
|
||||
SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, TOUCH_FLAG_NONE,
|
||||
TOUCH_MASK_NONE,
|
||||
};
|
||||
|
||||
/// Re-inject cadence while state is held (in-range pen / touching contacts). Apollo uses
|
||||
/// 50 ms against the ~100 ms staleness window; 40 ms keeps two refreshes inside it.
|
||||
const REFRESH_MS: u64 = 40;
|
||||
|
||||
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
|
||||
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
|
||||
|
||||
/// Map a normalized [0,1] coordinate pair onto virtual-desktop pixels — the same surface the
|
||||
/// SendInput absolute mouse targets, so pen, touch, and pointer all land identically.
|
||||
fn to_screen(x: f32, y: f32) -> POINT {
|
||||
// SAFETY: `GetSystemMetrics` takes a constant index and reads global metrics; no memory in.
|
||||
let (vx, vy, vw, vh) = unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
|
||||
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
|
||||
)
|
||||
};
|
||||
POINT {
|
||||
x: vx + (x.clamp(0.0, 1.0) * (vw - 1) as f32) as i32,
|
||||
y: vy + (y.clamp(0.0, 1.0) * (vh - 1) as f32) as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop).
|
||||
struct Device(HSYNTHETICPOINTERDEVICE);
|
||||
|
||||
// SAFETY: the handle is a plain kernel object identifier with no thread affinity —
|
||||
// `InjectSyntheticPointerInput`/`DestroySyntheticPointerDevice` are documented callable from
|
||||
// any thread; ownership transfer/sharing does not alias memory.
|
||||
unsafe impl Send for Device {}
|
||||
|
||||
impl Drop for Device {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the device this wrapper uniquely owns; destroyed once here.
|
||||
unsafe { DestroySyntheticPointerDevice(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe: can this Windows build create a synthetic pen device (Win10 1809+)?
|
||||
pub fn synthetic_pen_available() -> bool {
|
||||
// SAFETY: FFI create with by-value args; on success the returned handle is destroyed
|
||||
// immediately by the `Device` wrapper, exactly once.
|
||||
match unsafe { CreateSyntheticPointerDevice(PT_PEN, 1, POINTER_FEEDBACK_DEFAULT) } {
|
||||
Ok(h) => {
|
||||
drop(Device(h));
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tracked pen state a refresh tick re-asserts.
|
||||
#[derive(Default)]
|
||||
struct PenState {
|
||||
in_range: bool,
|
||||
touching: bool,
|
||||
barrel: bool,
|
||||
eraser: bool,
|
||||
x: f32,
|
||||
y: f32,
|
||||
pressure: u16,
|
||||
tilt_deg: u8,
|
||||
azimuth_deg: u16,
|
||||
roll_deg: u16,
|
||||
}
|
||||
|
||||
struct PenShared {
|
||||
dev: Device,
|
||||
state: PenState,
|
||||
}
|
||||
|
||||
/// One per-session virtual pen (the Windows sibling of the Linux uinput tablet — same
|
||||
/// [`PenTransition`] consumer API). Wire barrel button 2 has no Windows pen equivalent
|
||||
/// (one barrel + eraser is the platform model) and is ignored here.
|
||||
pub struct VirtualPen {
|
||||
shared: Arc<Mutex<PenShared>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
refresher: Option<std::thread::JoinHandle<()>>,
|
||||
// Batch-local frame grouping (single-threaded within apply_batch).
|
||||
edge_down: bool,
|
||||
edge_up: bool,
|
||||
is_new: bool,
|
||||
frame_dirty: bool,
|
||||
frame_has_motion: bool,
|
||||
}
|
||||
|
||||
impl VirtualPen {
|
||||
pub fn create() -> Result<VirtualPen> {
|
||||
// SAFETY: FFI create with by-value args; the handle's sole owner becomes `Device`.
|
||||
let dev = unsafe { CreateSyntheticPointerDevice(PT_PEN, 1, POINTER_FEEDBACK_DEFAULT) }
|
||||
.context("CreateSyntheticPointerDevice(PT_PEN) — needs Windows 10 1809+")?;
|
||||
let shared = Arc::new(Mutex::new(PenShared {
|
||||
dev: Device(dev),
|
||||
state: PenState::default(),
|
||||
}));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
// The staleness guard: re-assert the last frame while in range so a stationary pen
|
||||
// (native plane: between 100 ms heartbeats; GameStream: indefinitely) never hovers out.
|
||||
let refresher = {
|
||||
let shared = Arc::clone(&shared);
|
||||
let stop = Arc::clone(&stop);
|
||||
std::thread::Builder::new()
|
||||
.name("pf-pen-refresh".into())
|
||||
.spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS));
|
||||
let s = shared.lock().unwrap();
|
||||
if s.state.in_range {
|
||||
inject_pen(&s.dev, &s.state, POINTER_FLAG_UPDATE, false);
|
||||
}
|
||||
}
|
||||
})
|
||||
.context("spawn pen refresh thread")?
|
||||
};
|
||||
tracing::info!("virtual pen created (Windows synthetic pointer, PT_PEN)");
|
||||
Ok(VirtualPen {
|
||||
shared,
|
||||
stop,
|
||||
refresher: Some(refresher),
|
||||
edge_down: false,
|
||||
edge_up: false,
|
||||
is_new: false,
|
||||
frame_dirty: false,
|
||||
frame_has_motion: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply one batch of tracker transitions — same grouping contract as the Linux backend:
|
||||
/// `[ProxIn, Motion, TipDown]` is ONE frame (entry lands at its position, in contact),
|
||||
/// consecutive `Motion`s split, tip edges own their frames, range-leave is a final
|
||||
/// no-`INRANGE` frame.
|
||||
pub fn apply_batch(&mut self, transitions: &[PenTransition]) {
|
||||
for t in transitions {
|
||||
match t {
|
||||
PenTransition::ProximityIn { tool } => {
|
||||
self.flush();
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
s.state.in_range = true;
|
||||
s.state.eraser = *tool == PenTool::Eraser;
|
||||
drop(s);
|
||||
self.is_new = true;
|
||||
self.frame_dirty = true;
|
||||
}
|
||||
PenTransition::Motion { sample } => {
|
||||
if self.frame_has_motion {
|
||||
self.flush();
|
||||
}
|
||||
self.set_axes(sample);
|
||||
self.frame_dirty = true;
|
||||
self.frame_has_motion = true;
|
||||
}
|
||||
PenTransition::TipDown => {
|
||||
self.shared.lock().unwrap().state.touching = true;
|
||||
self.edge_down = true;
|
||||
self.frame_dirty = true;
|
||||
}
|
||||
PenTransition::ButtonsChanged { pressed, released } => {
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
if pressed & PEN_BARREL1 != 0 {
|
||||
s.state.barrel = true;
|
||||
}
|
||||
if released & PEN_BARREL1 != 0 {
|
||||
s.state.barrel = false;
|
||||
}
|
||||
drop(s);
|
||||
self.frame_dirty = true;
|
||||
}
|
||||
PenTransition::TipUp => {
|
||||
self.shared.lock().unwrap().state.touching = false;
|
||||
self.edge_up = true;
|
||||
self.frame_dirty = true;
|
||||
self.flush(); // UP owns its frame (still INRANGE)
|
||||
}
|
||||
PenTransition::ProximityOut => {
|
||||
self.shared.lock().unwrap().state.in_range = false;
|
||||
self.frame_dirty = true;
|
||||
self.flush(); // final frame without INRANGE
|
||||
}
|
||||
}
|
||||
}
|
||||
self.flush();
|
||||
}
|
||||
|
||||
fn set_axes(&mut self, s: &PenSample) {
|
||||
let mut sh = self.shared.lock().unwrap();
|
||||
sh.state.x = s.x;
|
||||
sh.state.y = s.y;
|
||||
sh.state.pressure = s.pressure;
|
||||
sh.state.tilt_deg = s.tilt_deg;
|
||||
sh.state.azimuth_deg = s.azimuth_deg;
|
||||
sh.state.roll_deg = s.roll_deg;
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
if !self.frame_dirty {
|
||||
return;
|
||||
}
|
||||
let edge = if self.edge_down {
|
||||
POINTER_FLAG_DOWN
|
||||
} else if self.edge_up {
|
||||
POINTER_FLAG_UP
|
||||
} else {
|
||||
POINTER_FLAG_UPDATE
|
||||
};
|
||||
let s = self.shared.lock().unwrap();
|
||||
inject_pen(&s.dev, &s.state, edge, self.is_new);
|
||||
drop(s);
|
||||
self.edge_down = false;
|
||||
self.edge_up = false;
|
||||
self.is_new = false;
|
||||
self.frame_dirty = false;
|
||||
self.frame_has_motion = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VirtualPen {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(h) = self.refresher.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
// The device itself dies with `shared` (Device::drop) — Windows releases any held
|
||||
// in-range/contact state when the synthetic device is destroyed.
|
||||
}
|
||||
}
|
||||
|
||||
/// Build + inject one pen frame from tracked state. `edge` is DOWN/UP/UPDATE;
|
||||
/// `INRANGE`/`INCONTACT` derive from the state itself.
|
||||
fn inject_pen(dev: &Device, st: &PenState, edge: POINTER_FLAGS, is_new: bool) {
|
||||
let mut flags = edge;
|
||||
if st.in_range {
|
||||
flags |= POINTER_FLAG_INRANGE;
|
||||
}
|
||||
if st.touching {
|
||||
flags |= POINTER_FLAG_INCONTACT | POINTER_FLAG_FIRSTBUTTON;
|
||||
}
|
||||
if is_new {
|
||||
flags |= POINTER_FLAG_NEW;
|
||||
}
|
||||
let mut pen_flags = PEN_FLAG_NONE;
|
||||
if st.barrel {
|
||||
pen_flags |= PEN_FLAG_BARREL;
|
||||
}
|
||||
if st.eraser {
|
||||
pen_flags |= PEN_FLAG_INVERTED;
|
||||
if st.touching {
|
||||
pen_flags |= PEN_FLAG_ERASER;
|
||||
}
|
||||
}
|
||||
let mut pen_mask = PEN_MASK_PRESSURE;
|
||||
// Contact needs a nonzero pressure to ink; hover reports 0 (Windows convention).
|
||||
let pressure = if st.touching {
|
||||
((st.pressure as u32 * WIN_PEN_PRESSURE_MAX) / u16::MAX as u32).max(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (mut tilt_x, mut tilt_y) = (0i32, 0i32);
|
||||
if st.tilt_deg != PEN_TILT_UNKNOWN && st.azimuth_deg != PEN_ANGLE_UNKNOWN {
|
||||
let az = (st.azimuth_deg as f32).to_radians();
|
||||
let tilt = st.tilt_deg as f32;
|
||||
tilt_x = (tilt * az.sin()).round() as i32;
|
||||
tilt_y = (-tilt * az.cos()).round() as i32;
|
||||
pen_mask |= PEN_MASK_TILT_X | PEN_MASK_TILT_Y;
|
||||
}
|
||||
let mut rotation = 0u32;
|
||||
if st.roll_deg != PEN_ANGLE_UNKNOWN {
|
||||
rotation = (st.roll_deg % 360) as u32;
|
||||
pen_mask |= PEN_MASK_ROTATION;
|
||||
}
|
||||
let pt = to_screen(st.x, st.y);
|
||||
let info = POINTER_TYPE_INFO {
|
||||
r#type: PT_PEN,
|
||||
Anonymous: POINTER_TYPE_INFO_0 {
|
||||
penInfo: POINTER_PEN_INFO {
|
||||
pointerInfo: POINTER_INFO {
|
||||
pointerType: PT_PEN,
|
||||
pointerId: 0,
|
||||
pointerFlags: flags,
|
||||
ptPixelLocation: pt,
|
||||
ptPixelLocationRaw: pt,
|
||||
..Default::default()
|
||||
},
|
||||
penFlags: pen_flags,
|
||||
penMask: pen_mask,
|
||||
pressure,
|
||||
rotation,
|
||||
tiltX: tilt_x,
|
||||
tiltY: tilt_y,
|
||||
},
|
||||
},
|
||||
};
|
||||
// SAFETY: `dev.0` is the live device this wrapper owns; the one-element array is a live
|
||||
// stack value the call only reads. Best-effort like every injector write — a transient
|
||||
// failure (desktop switch) is healed by the next refresh tick re-asserting state.
|
||||
if let Err(e) = unsafe { InjectSyntheticPointerInput(dev.0, &[info]) } {
|
||||
tracing::trace!(error = %e, "pen inject failed (transient)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One live wire-touch contact.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Contact {
|
||||
id: u32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
/// Windows can inject at most this many simultaneous synthetic touch contacts.
|
||||
const MAX_CONTACTS: usize = 10;
|
||||
|
||||
struct TouchShared {
|
||||
dev: Device,
|
||||
contacts: Vec<Contact>,
|
||||
}
|
||||
|
||||
/// The `PT_TOUCH` device servicing wire `TouchDown/Move/Up` events — closes the SendInput
|
||||
/// touch no-op. Contacts are keyed by the wire's finger id; every frame re-injects the FULL
|
||||
/// active set (the synthetic-pointer contract), and a refresh thread re-asserts held contacts
|
||||
/// against the ~100 ms staleness auto-lift.
|
||||
pub struct SyntheticTouch {
|
||||
shared: Arc<Mutex<TouchShared>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
refresher: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SyntheticTouch {
|
||||
pub fn create() -> Result<SyntheticTouch> {
|
||||
// SAFETY: FFI create with by-value args; the handle's sole owner becomes `Device`.
|
||||
let dev = unsafe {
|
||||
CreateSyntheticPointerDevice(PT_TOUCH, MAX_CONTACTS as u32, POINTER_FEEDBACK_DEFAULT)
|
||||
}
|
||||
.context("CreateSyntheticPointerDevice(PT_TOUCH) — needs Windows 10 1809+")?;
|
||||
let shared = Arc::new(Mutex::new(TouchShared {
|
||||
dev: Device(dev),
|
||||
contacts: Vec::new(),
|
||||
}));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let refresher = {
|
||||
let shared = Arc::clone(&shared);
|
||||
let stop = Arc::clone(&stop);
|
||||
std::thread::Builder::new()
|
||||
.name("pf-touch-refresh".into())
|
||||
.spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(REFRESH_MS));
|
||||
let s = shared.lock().unwrap();
|
||||
if !s.contacts.is_empty() {
|
||||
inject_touch_frame(&s.dev, &s.contacts, None);
|
||||
}
|
||||
}
|
||||
})
|
||||
.context("spawn touch refresh thread")?
|
||||
};
|
||||
tracing::info!("virtual touchscreen created (Windows synthetic pointer, PT_TOUCH)");
|
||||
Ok(SyntheticTouch {
|
||||
shared,
|
||||
stop,
|
||||
refresher: Some(refresher),
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply one wire touch event (`code` = finger id, pixel x/y against the
|
||||
/// `flags = (w << 16) | h` reference extent, exactly like `MouseMoveAbs`).
|
||||
pub fn apply(&mut self, ev: &InputEvent) {
|
||||
let (w, h) = ((ev.flags >> 16) as f32, (ev.flags & 0xFFFF) as f32);
|
||||
if (w < 1.0 || h < 1.0) && ev.kind != InputKind::TouchUp {
|
||||
return; // the documented zero-extent drop, as for MouseMoveAbs
|
||||
}
|
||||
let (x, y) = (ev.x as f32 / w.max(1.0), ev.y as f32 / h.max(1.0));
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
match ev.kind {
|
||||
InputKind::TouchDown => {
|
||||
match s.contacts.iter().position(|c| c.id == ev.code) {
|
||||
Some(i) => (s.contacts[i].x, s.contacts[i].y) = (x, y),
|
||||
None if s.contacts.len() < MAX_CONTACTS => {
|
||||
s.contacts.push(Contact { id: ev.code, x, y })
|
||||
}
|
||||
None => return, // beyond the platform max — drop, never evict a live finger
|
||||
}
|
||||
inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_DOWN)));
|
||||
}
|
||||
InputKind::TouchMove => {
|
||||
match s.contacts.iter().position(|c| c.id == ev.code) {
|
||||
Some(i) => {
|
||||
(s.contacts[i].x, s.contacts[i].y) = (x, y);
|
||||
inject_touch_frame(&s.dev, &s.contacts, None);
|
||||
}
|
||||
// A move for an unknown id (its DOWN was dropped/lost): synthesize the
|
||||
// contact so the stroke self-heals, like the pen plane does.
|
||||
None if s.contacts.len() < MAX_CONTACTS => {
|
||||
s.contacts.push(Contact { id: ev.code, x, y });
|
||||
inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_DOWN)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
InputKind::TouchUp => {
|
||||
let Some(idx) = s.contacts.iter().position(|c| c.id == ev.code) else {
|
||||
return;
|
||||
};
|
||||
// The UP frame still carries the lifting contact (with UP flags), then it
|
||||
// leaves the active set.
|
||||
inject_touch_frame(&s.dev, &s.contacts, Some((ev.code, POINTER_FLAG_UP)));
|
||||
s.contacts.remove(idx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SyntheticTouch {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(h) = self.refresher.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject the full active-contact frame; `edge` marks one contact's DOWN/UP transition
|
||||
/// (everyone else is a held UPDATE).
|
||||
fn inject_touch_frame(dev: &Device, contacts: &[Contact], edge: Option<(u32, POINTER_FLAGS)>) {
|
||||
if contacts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut frame: Vec<POINTER_TYPE_INFO> = Vec::with_capacity(contacts.len());
|
||||
for c in contacts {
|
||||
let mut flags = POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_UPDATE;
|
||||
if let Some((id, e)) = edge {
|
||||
if id == c.id {
|
||||
if e == POINTER_FLAG_DOWN {
|
||||
flags = POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_DOWN;
|
||||
} else if e == POINTER_FLAG_UP {
|
||||
flags = POINTER_FLAG_UP; // contact + range end together
|
||||
}
|
||||
}
|
||||
}
|
||||
let pt = to_screen(c.x, c.y);
|
||||
frame.push(POINTER_TYPE_INFO {
|
||||
r#type: PT_TOUCH,
|
||||
Anonymous: POINTER_TYPE_INFO_0 {
|
||||
touchInfo: POINTER_TOUCH_INFO {
|
||||
pointerInfo: POINTER_INFO {
|
||||
pointerType: PT_TOUCH,
|
||||
pointerId: c.id,
|
||||
pointerFlags: flags,
|
||||
ptPixelLocation: pt,
|
||||
ptPixelLocationRaw: pt,
|
||||
..Default::default()
|
||||
},
|
||||
touchFlags: TOUCH_FLAG_NONE,
|
||||
touchMask: TOUCH_MASK_NONE,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// SAFETY: `dev.0` is the live owned device; `frame` is a live Vec the call only reads.
|
||||
// Best-effort — a transient failure heals on the next event/refresh re-assertion.
|
||||
if let Err(e) = unsafe { InjectSyntheticPointerInput(dev.0, &frame) } {
|
||||
tracing::trace!(error = %e, "touch inject failed (transient)");
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,11 @@ const XBUTTON2: u32 = 0x0002;
|
||||
|
||||
pub struct SendInputInjector {
|
||||
desktop: Option<HDESK>,
|
||||
/// PT_TOUCH synthetic device, created on the first wire-touch event (a session that never
|
||||
/// touches never creates one). `None` after a failed create (pre-1809) — touch then stays
|
||||
/// the historical no-op.
|
||||
touch: Option<crate::pen::SyntheticTouch>,
|
||||
touch_failed: bool,
|
||||
}
|
||||
|
||||
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
|
||||
@@ -58,7 +63,11 @@ unsafe impl Send for SendInputInjector {}
|
||||
|
||||
impl SendInputInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let mut me = Self { desktop: None };
|
||||
let mut me = Self {
|
||||
desktop: None,
|
||||
touch: None,
|
||||
touch_failed: false,
|
||||
};
|
||||
me.reattach_input_desktop(); // best-effort
|
||||
tracing::info!("SendInput injector ready (Win32 KeyboardAndMouse)");
|
||||
Ok(me)
|
||||
@@ -324,15 +333,33 @@ impl InputInjector for SendInputInjector {
|
||||
}
|
||||
self.send(&inputs)
|
||||
}
|
||||
// Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
|
||||
// Gamepad goes through the XUSB backend.
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => Ok(()),
|
||||
| InputKind::GamepadArrival => Ok(()),
|
||||
// Wire touch → the PT_TOUCH synthetic pointer device (design/pen-tablet-input.md
|
||||
// §6; closes the historical SendInput no-op). Lazily created — a session that never
|
||||
// touches never creates one; a pre-1809 create failure latches back to the no-op.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||
if self.touch.is_none() && !self.touch_failed {
|
||||
match crate::pen::SyntheticTouch::create() {
|
||||
Ok(t) => self.touch = Some(t),
|
||||
Err(e) => {
|
||||
self.touch_failed = true;
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"touch: synthetic pointer unavailable — wire touch stays a no-op"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = self.touch.as_mut() {
|
||||
t.apply(event);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +223,20 @@ pub fn pen_supported() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// See the Linux variant — no pen injection off Linux yet (Windows PT_PEN is design P3).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
/// Windows: pen (and touch) inject via synthetic pointer devices — available on Win10 1809+,
|
||||
/// probed by actually creating (and immediately destroying) a PT_PEN device. Same
|
||||
/// `PUNKTFUNK_PEN=0` kill-switch as Linux. The probe result also stands in for PT_TOUCH
|
||||
/// (both APIs arrived together in 1809).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn pen_supported() -> bool {
|
||||
if std::env::var("PUNKTFUNK_PEN").as_deref() == Ok("0") {
|
||||
return false;
|
||||
}
|
||||
pen::synthetic_pen_available()
|
||||
}
|
||||
|
||||
/// See the Linux/Windows variants — no pen injection elsewhere.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn pen_supported() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -399,9 +411,15 @@ pub mod gamepad {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/pen.rs"]
|
||||
pub mod pen;
|
||||
/// Stub — pen injection needs the Linux uinput tablet (Windows PT_PEN is design P3);
|
||||
/// Windows: PT_PEN/PT_TOUCH synthetic pointer devices (design/pen-tablet-input.md §6).
|
||||
/// `pen::VirtualPen` here is the PT_PEN device; `pen::SyntheticTouch` backs the SendInput
|
||||
/// injector's wire-touch path.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/pointer_windows.rs"]
|
||||
pub mod pen;
|
||||
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
|
||||
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod pen {
|
||||
use anyhow::{bail, Result};
|
||||
pub struct VirtualPen;
|
||||
|
||||
Reference in New Issue
Block a user