diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts index 69ae80e3..d0b93adf 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts @@ -56,13 +56,25 @@ describe("PipeWireCursorAccumulator", () => { expect(point.timeMs).toBe(500); }); - it("reports every sample as a move, because Wayland exposes no buttons", () => { + it("defaults a sample with no interaction to a move", () => { + // The helper omits interactionType on the common case, so the accumulator + // owns the "move" fallback. This is what the user not being in the `input` + // group looks like: every sample arrives bare. const accumulator = new PipeWireCursorAccumulator(100); accumulator.reset(0); accumulator.addSample(sample(10, 5, 5)); expect(accumulator.toRecordingData().samples[0].interactionType).toBe("move"); }); + it("preserves a click the helper read from evdev", () => { + const accumulator = new PipeWireCursorAccumulator(100); + accumulator.reset(0); + accumulator.addSample(sample(10, 5, 5, { interactionType: "click" })); + accumulator.addSample(sample(20, 6, 6)); + const { samples } = accumulator.toRecordingData(); + expect(samples.map((s) => s.interactionType)).toEqual(["click", "move"]); + }); + it("re-bases onto the video's start and drops what came before it", () => { // This is the single-session case. Cursor samples start flowing as soon // as the helper does, but the video's frame 0 is only stamped once the diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts index 84a13130..4fa43409 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts @@ -57,6 +57,11 @@ export type PipeWireHelperEvent = visible: boolean; assetId?: string; asset?: PipeWireCursorAssetPayload; + /** `"click"` on the sample coinciding with a left-button press the + * helper read from evdev; absent on a plain move (see the helper's + * input.rs). The helper never emits the `"move"` default — that word + * is filled in below so it lives in exactly one place. */ + interactionType?: "move" | "click"; } | { event: "audio-source"; @@ -166,8 +171,10 @@ export class PipeWireCursorAccumulator { cx: clamp(payload.x / width, 0, 1), cy: clamp(payload.y / height, 0, 1), visible: payload.visible, - // Wayland exposes no click events to an unprivileged process. - interactionType: "move", + // The portal never reports a button; the helper tags a sample "click" + // only when it read a left-button press from evdev (needs the user in + // the `input` group). Everything else — the common case — is a move. + interactionType: payload.interactionType ?? "move", ...(payload.assetId ? { assetId: payload.assetId } : {}), }); diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts index 16815071..7dd222ce 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts @@ -21,8 +21,11 @@ import type { CursorRecordingSession } from "./session"; * * Two consequences the caller should know about: * - * * `interactionType` is always "move". Wayland exposes no portal for mouse - * buttons and /dev/input/event* is root:input, so clicks are unobtainable. + * * `interactionType` is "move" unless the helper could read left-button + * presses from evdev — which needs the user in the `input` group, because + * Wayland exposes no portal for mouse buttons and /dev/input/event* is + * root:input. When it can, the coinciding sample is tagged "click"; when it + * cannot, every sample is a move, as before. See the helper's input.rs. * * The helper raises its own portal picker. On Wayland, Electron's * `desktopCapturer` already raised one, so the user currently picks a source * twice. Merging the two is the job of the capture stage that will reuse this diff --git a/electron/native/README.md b/electron/native/README.md index 8ff2e3cd..4b12c706 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -89,8 +89,8 @@ Encoder selection: by default the helper keeps the existing sink-writer path fir Frame input path: the helper feeds the encoder from the GPU when it can. On that path it copies the WGC frame across a keyed-mutex bridge to a second D3D11 device, converts BGRA to NV12 with the D3D11 video processor, and submits an allocator-owned DXGI sample to the hardware H.264 encoder, so no frame ever passes through system memory. The alternative is the original path: a staging texture, `Map(D3D11_MAP_READ)`, and a row-by-row copy into an `IMFMediaBuffer` — which is where a driver stall costs a recording (issue #252). The GPU path is a preference, never a requirement: it is skipped outright for `preferSoftwareEncoder` and for inline webcam PiP (both need the frame in system memory), and it degrades to the CPU path on its own if the encoding device, the NV12 video processor, the shared bridge texture, the DXGI sample allocator, or the hardware sink writer is unavailable. The GPU path is OFF by default: it fixed #252 on the machine that reproduces it and broke recording outright in #336, and its fallbacks only cover failures during `initialize()`, not one that appears once frames are flowing. Set `OPENSCREEN_WGC_ENABLE_DXGI_INPUT=1` to turn it on. Because the two paths land on different encoders and hardware MFTs default to constant bitrate, the GPU path asks for VBR through `ICodecAPI`; without it a static screen spends the full configured budget (measured 16.9 Mbps against 1.95 for the same desktop). -The helper reports the outcome through the `encoder-selection` stdout event (`video` is `default`, `software-preferred`, or `software-fallback`; `videoInput` is `dxgi-nv12` or `cpu-rgb32`; `container` is `fragmented-mp4` or `mp4`; all three report what the encoder settled on rather than what was asked for). On the GPU path the helper also prints one `[frame-drops] gpu_bridge_contended=` line to stderr at stop: a frame the bridge was too busy to take is skipped rather than failing the recording, and a large count there is the first thing to look at in a report about missing frames. When the app sees `software-fallback` — the default encoder failed and the helper switched on its own — it shows a small dismissible notice in the recording HUD with a "Don't show again" option, because software encoding can raise CPU usage. An explicit `software-preferred` selection shows no notice, and the event stays available for diagnostics either way. - +The helper reports the outcome through the `encoder-selection` stdout event (`video` is `default`, `software-preferred`, or `software-fallback`; `videoInput` is `dxgi-nv12` or `cpu-rgb32`; `container` is `fragmented-mp4` or `mp4`; all three report what the encoder settled on rather than what was asked for). On the GPU path the helper also prints one `[frame-drops] gpu_bridge_contended=` line to stderr at stop: a frame the bridge was too busy to take is skipped rather than failing the recording, and a large count there is the first thing to look at in a report about missing frames. When the app sees `software-fallback` — the default encoder failed and the helper switched on its own — it shows a small dismissible notice in the recording HUD with a "Don't show again" option, because software encoding can raise CPU usage. An explicit `software-preferred` selection shows no notice, and the event stays available for diagnostics either way. + At startup the helper also emits `capture-adapter`, naming the GPU its D3D device landed on and the one actually driving the captured display, each with its LUID, plus one `[adapters]` line per enumerated adapter on stderr. `createD3DDevice` asks for the *default* adapter and nothing checks that it is the one driving the display; when they differ every frame crosses an adapter boundary before the caller touches it. The LUIDs are there because the descriptions are not enough to tell: an IddCx virtual display driver renders through the physical GPU and inherits its description string while being a separate DXGI adapter, so the configuration this diagnostic exists to catch is precisely the one where both names are identical and only the LUIDs differ (measured: `NVIDIA Quadro RTX 4000` at LUID `0:24084` driving the display, the same string at `0:12889146` for the virtual adapter). `monitorLookup` says which of three things happened: `ok`, `no-output-claims-it` (the enumeration finished and nothing owns the captured monitor, which is what an active virtual display looks like), or `unavailable` (`EnumOutputs` refused, as it does in session 0 — the outputs were never inspected, so the absence means nothing about the hardware). Encoder diagnostic on final sink-writer failure: when the final sink-writer attempt fails (`MFCreateSinkWriterFromMediaSink` on the fragmented container, `MFCreateSinkWriterFromURL` on the plain one; the message names which), the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. There is still no fail-fast pre-flight gate because `MFTEnumEx` and the sink writer can disagree about which H.264 encoders are available in non-interactive / Session 0 contexts. @@ -209,9 +209,14 @@ electron/native/bin/linux-x64/openscreen-pipewire-helper '{"probeOnly":true}' ### Known gaps -- **Mouse clicks are unobtainable.** Wayland exposes no portal for input events - and `/dev/input/event*` is `root:input`, so every sample's `interactionType` is - `"move"`. +- **Mouse clicks need the `input` group.** Wayland exposes no portal for input + events, so the helper reads left-button presses straight from evdev + (`/dev/input/event*`). Those nodes are `root:input`, so a user outside the + `input` group gets no readable device and every sample's `interactionType` + stays `"move"` — the same as before. When a device is readable, the coinciding + sample is tagged `"click"`. Scope is deliberately narrow: `BTN_LEFT` only, + never keystrokes (see `pipewire-capture/src/input.rs`), and + `OPENSCREEN_DISABLE_CLICK_CAPTURE=1` turns it off entirely. - **The user picks a source twice.** Electron's `desktopCapturer` raises its own portal dialog for the video, and this helper raises a second one for the cursor. Collapsing them requires one portal session serving both, which is why the diff --git a/electron/native/pipewire-capture/Cargo.lock b/electron/native/pipewire-capture/Cargo.lock index f8fa27a5..17c2abd0 100644 --- a/electron/native/pipewire-capture/Cargo.lock +++ b/electron/native/pipewire-capture/Cargo.lock @@ -238,6 +238,18 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -415,6 +427,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -475,6 +499,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.33" @@ -834,6 +864,7 @@ dependencies = [ "base64", "bindgen", "cc", + "evdev", "png", "pollster", "serde", @@ -974,6 +1005,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.7" @@ -1213,6 +1250,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -1463,6 +1506,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xdg-home" version = "1.3.0" diff --git a/electron/native/pipewire-capture/Cargo.toml b/electron/native/pipewire-capture/Cargo.toml index 604da16a..862b848b 100644 --- a/electron/native/pipewire-capture/Cargo.toml +++ b/electron/native/pipewire-capture/Cargo.toml @@ -50,6 +50,7 @@ serde_json = "1" png = "0.17" base64 = "0.22" sha2 = "0.10" +evdev = "0.13" [build-dependencies] cc = "1" diff --git a/electron/native/pipewire-capture/src/events.rs b/electron/native/pipewire-capture/src/events.rs index 085ea56a..ce98ac87 100644 --- a/electron/native/pipewire-capture/src/events.rs +++ b/electron/native/pipewire-capture/src/events.rs @@ -97,6 +97,12 @@ pub enum Event { asset_id: Option, #[serde(skip_serializing_if = "Option::is_none")] asset: Option, + /// `"click"` on the sample that coincides with a left-button press read + /// from evdev (see `input.rs`), absent otherwise. Omitted rather than + /// defaulted to `"move"` so the accumulator keeps that fallback in one + /// place and the wire stays quiet on the common case. + #[serde(skip_serializing_if = "Option::is_none")] + interaction_type: Option, }, /// Which capture node each audio source was linked to. /// @@ -303,12 +309,32 @@ mod tests { visible: true, asset_id: None, asset: None, + interaction_type: None, }); assert_eq!(value["event"], "cursor-sample"); assert_eq!(value["x"], 100); assert_eq!(value["visible"], true); assert!(value.get("assetId").is_none()); assert!(value.get("asset").is_none()); + // A plain move stays silent about its interaction so the accumulator's + // "move" default is the single source of that word. + assert!(value.get("interactionType").is_none()); + } + + #[test] + fn cursor_samples_report_a_click_when_tagged() { + let value = parse_one(&Event::CursorSample { + timestamp_ms: 12, + x: 100, + y: 200, + width: 1920, + height: 1080, + visible: true, + asset_id: None, + asset: None, + interaction_type: Some("click".to_owned()), + }); + assert_eq!(value["interactionType"], "click"); } #[test] @@ -329,6 +355,7 @@ mod tests { hotspot_x: 4, hotspot_y: 3, }), + interaction_type: None, }); assert_eq!(value["assetId"], "abc"); assert_eq!(value["asset"]["imageDataUrl"], "data:image/png;base64,AA=="); @@ -359,6 +386,7 @@ mod tests { visible: true, asset_id: None, asset: None, + interaction_type: None, }); assert_eq!(value["timestampMs"], 1234, "a sample's capture time must not be overwritten"); } diff --git a/electron/native/pipewire-capture/src/input.rs b/electron/native/pipewire-capture/src/input.rs new file mode 100644 index 00000000..a94f2188 --- /dev/null +++ b/electron/native/pipewire-capture/src/input.rs @@ -0,0 +1,119 @@ +//! Left mouse-button telemetry on Wayland, read from evdev. +//! +//! WHY THIS EXISTS. Wayland deliberately denies an unprivileged process any view +//! of global input: the ScreenCast portal reports cursor POSITION as frame +//! metadata but never button state, and the only portal that streams input +//! (`InputCapture`) *grabs* it, redirecting clicks away from the app being +//! recorded — useless while the user is demoing. The one remaining source is the +//! kernel's evdev interface (`/dev/input/event*`). Reading it needs membership in +//! the `input` group (the nodes are `root:input`), which is the user's own, +//! out-of-band act of consent — the Wayland equivalent of the button state the +//! macOS and Windows helpers already read from their native APIs. +//! +//! SCOPE AND PRIVACY. A pointer node can also deliver keystrokes on a combined +//! keyboard+mouse device. This reader inspects ONLY `EV_KEY` events whose code is +//! `BTN_LEFT`, and only their press edge; it never reads, stores, or forwards any +//! other key code, and it only ever opens devices that advertise `BTN_LEFT` in +//! the first place. Set `OPENSCREEN_DISABLE_CLICK_CAPTURE=1` to turn it off +//! entirely even where the permission exists. + +use std::sync::mpsc::Sender; +use std::thread; + +use evdev::{Device, EventType, KeyCode}; + +use crate::Message; + +const DISABLE_ENV: &str = "OPENSCREEN_DISABLE_CLICK_CAPTURE"; + +/// True when this evdev event is the press edge of the left mouse button. +/// +/// Extracted as a pure function so the decision is unit-testable without a real +/// device: a release (`value == 0`), an autorepeat (`value == 2`), and every +/// non-`BTN_LEFT` code — including every keyboard key — must NOT count. +pub fn is_left_button_press(event_type: EventType, code: u16, value: i32) -> bool { + event_type == EventType::KEY && code == KeyCode::BTN_LEFT.0 && value == 1 +} + +/// Opens every readable pointer device that reports `BTN_LEFT` and spawns a +/// reader thread per device. Returns whether at least one was opened — the caller +/// uses that to tell the log why Linux clicks are or are not being captured. +/// +/// Never fails: an unreadable node (the common case, when the user is not in the +/// `input` group) is skipped by `evdev::enumerate`, and no readable node at all +/// simply means every sample stays `"move"`, exactly as before this existed. +pub fn spawn_readers(sender: &Sender) -> bool { + if std::env::var_os(DISABLE_ENV).is_some() { + return false; + } + let mut opened = 0usize; + for (_path, device) in evdev::enumerate() { + if !device_reports_left_button(&device) { + continue; + } + opened += 1; + let forward = sender.clone(); + thread::spawn(move || read_device(device, forward)); + } + opened > 0 +} + +fn device_reports_left_button(device: &Device) -> bool { + device + .supported_keys() + .is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT)) +} + +/// Blocks reading `device`, forwarding one `PointerButton` message per left-button +/// press. Returns when the device errors (e.g. unplugged) or the loop's channel +/// has closed, so the thread cannot outlive the recording it serves. +fn read_device(mut device: Device, sender: Sender) { + loop { + let events = match device.fetch_events() { + Ok(events) => events, + Err(_) => return, + }; + for event in events { + if is_left_button_press(event.event_type(), event.code(), event.value()) + && sender.send(Message::PointerButton).is_err() + { + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BTN_LEFT: u16 = KeyCode::BTN_LEFT.0; + const BTN_RIGHT: u16 = KeyCode::BTN_RIGHT.0; + + #[test] + fn a_left_button_press_is_a_click() { + assert!(is_left_button_press(EventType::KEY, BTN_LEFT, 1)); + } + + #[test] + fn a_left_button_release_is_not() { + assert!(!is_left_button_press(EventType::KEY, BTN_LEFT, 0)); + } + + #[test] + fn a_left_button_autorepeat_is_not() { + // A held button emits value 2; only the 0->1 edge is a click. + assert!(!is_left_button_press(EventType::KEY, BTN_LEFT, 2)); + } + + #[test] + fn a_right_button_press_is_not_a_left_click() { + assert!(!is_left_button_press(EventType::KEY, BTN_RIGHT, 1)); + } + + #[test] + fn a_key_matching_btn_lefts_code_on_another_axis_is_not_a_click() { + // Same numeric code but a relative-motion event, not a key — must miss. + assert!(!is_left_button_press(EventType::RELATIVE, BTN_LEFT, 1)); + } +} diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 8ec4078c..e8f8e429 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -20,15 +20,19 @@ //! the cursor-only session Stage 1 shipped, which is what //! `PipeWireCursorRecordingSession` still uses. //! -//! WHAT IT CANNOT DO. Mouse buttons. Wayland exposes no portal for input -//! events, and /dev/input/event* is root:input. Every sample is therefore a -//! "move"; there is no click detection to be had here at any effort level. +//! MOUSE BUTTONS. Not from the portal — Wayland exposes no portal for input +//! events. The one source left is evdev (/dev/input/event*), which is root:input +//! and so needs the user in the `input` group. When that permission exists the +//! helper reads left-button presses and tags the coinciding sample "click"; when +//! it does not, every sample is a "move", as before. See `input.rs` for the +//! reader and its deliberately narrow scope (BTN_LEFT only, never keystrokes). mod bitmap; mod capture; mod encoder; mod events; mod ffmpeg; +mod input; mod portal; mod shim; @@ -208,6 +212,10 @@ struct AudioSourceConfig { enum Message { Portal(Box>), Stream(StreamEvent), + /// A left mouse-button press observed on evdev — the next cursor sample is + /// tagged `"click"`. See [`input`] for why this is the only way to see a + /// button on Wayland, and for its permission and privacy model. + PointerButton, /// Arm a deferred session: connect to PipeWire and start encoding. Record, Pause, @@ -283,6 +291,18 @@ fn main() { let (sender, receiver) = mpsc::channel::(); spawn_stdin_reader(sender.clone()); spawn_portal(sender.clone(), cursor_mode); + // Left-button telemetry from evdev. Absent when the user is not in the + // `input` group; a session that paints no cursor has no use for it either. + // Warn in that case so a "clicks do nothing on Linux" report is answerable + // from the log alone rather than looking like a capture bug. + if !input::spawn_readers(&sender) && cursor_mode.reports_cursor() { + let _ = emitter.emit(&Event::Warning { + code: "click-capture-unavailable".to_owned(), + message: "no readable /dev/input pointer device — add this user to the 'input' \ + group to record click telemetry; cursor samples will otherwise all be moves" + .to_owned(), + }); + } let session = RunConfig { tick, @@ -568,6 +588,8 @@ fn run( let mut cursor: Option = None; let mut known_assets: HashSet = HashSet::new(); let mut pending_asset: Option = None; + // Set by a `PointerButton` message, consumed by the next emitted sample. + let mut pending_click = false; let mut reported_cursor_meta = false; // Allocated up front so the PipeWire callback has somewhere to put frames // from the very first buffer; `None` in cursor-only mode, which is also what @@ -597,6 +619,12 @@ fn run( match receiver.recv_timeout(config.tick) { Ok(Message::Stop) => break, + // Latched, not emitted here: a bare press carries no position, so it + // waits for the next sample (which does) to become a `"click"`. + Ok(Message::PointerButton) => { + pending_click = true; + } + Ok(Message::Pause) => { paused = true; if let Some(capture) = capture.as_mut() { @@ -1052,14 +1080,14 @@ fn run( // A new sprite ships immediately; positions respect the sample // interval so a 120fps compositor cannot flood stdout. if asset_is_new || last_emit.elapsed() >= config.sample_interval { - emit_sample(emitter, &cursor, size, &mut pending_asset); + emit_sample(emitter, &cursor, size, &mut pending_asset, &mut pending_click); last_emit = Instant::now(); } } Err(RecvTimeoutError::Timeout) => { if cursor.is_some() && last_emit.elapsed() >= config.sample_interval { - emit_sample(emitter, &cursor, size, &mut pending_asset); + emit_sample(emitter, &cursor, size, &mut pending_asset, &mut pending_click); last_emit = Instant::now(); } // The heartbeat that keeps the output at a constant frame rate @@ -1153,11 +1181,22 @@ fn emit_sample( cursor: &Option, size: Option<(i32, i32)>, pending_asset: &mut Option, + pending_click: &mut bool, ) { let (Some(state), Some((width, height))) = (cursor, size) else { return; }; let visible = state.x >= 0 && state.y >= 0 && state.x < width && state.y < height; + // A click observed since the last sample rides out on this one. Cleared only + // when a sample is actually emitted, so a press that lands before the stream + // is live tags the first real sample rather than being dropped; at the sample + // cadence the cursor has not moved enough for the position to be wrong. + let interaction_type = if *pending_click { + *pending_click = false; + Some("click".to_owned()) + } else { + None + }; let _ = emitter.emit(&Event::CursorSample { timestamp_ms: timestamp_ms(), x: state.x, @@ -1167,6 +1206,7 @@ fn emit_sample( visible, asset_id: state.asset_id.clone(), asset: pending_asset.take(), + interaction_type, }); }