From be9c643a068f46ba315c6986f65e58d613f9009b Mon Sep 17 00:00:00 2001 From: Taylor Hiott Date: Tue, 27 Oct 2026 20:24:39 +0000 Subject: [PATCH 1/2] feat: add touchscreen cursor style --- crates/compositor/src/compositor_linux.rs | 8 +- crates/compositor/src/compositor_macos.rs | 10 ++ crates/compositor/src/compositor_windows.rs | 6 + crates/compositor/src/cursor.rs | 107 ++++++++++++++++-- crates/compositor/src/frame_geometry.rs | 85 +++++++++++++- .../pipeWireCursorAccumulator.test.ts | 10 ++ .../recording/pipeWireCursorAccumulator.ts | 8 +- .../pipeWireCursorRecordingSession.ts | 2 +- .../native/pipewire-capture/src/events.rs | 4 +- electron/native/pipewire-capture/src/input.rs | 43 +++---- electron/native/pipewire-capture/src/main.rs | 36 ++++-- public/cursors/touch/disk.png | Bin 0 -> 7323 bytes scripts/generate-touch-cursor.mjs | 26 +++++ .../ai-edition/CursorPane.levels.test.tsx | 16 +++ .../ai-edition/CursorPane.preview.test.tsx | 2 +- src/components/ai-edition/RightPanes.tsx | 11 +- src/i18n/locales/ar/settings.json | 1 + src/i18n/locales/cs/settings.json | 1 + src/i18n/locales/de/settings.json | 1 + src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 1 + src/i18n/locales/fr/settings.json | 1 + src/i18n/locales/it/settings.json | 1 + src/i18n/locales/ja-JP/settings.json | 1 + src/i18n/locales/ko-KR/settings.json | 1 + src/i18n/locales/pt-BR/settings.json | 1 + src/i18n/locales/ru/settings.json | 1 + src/i18n/locales/tr/settings.json | 1 + src/i18n/locales/vi/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 1 + src/lib/cursor/cursorThemes.test.ts | 27 ++++- src/lib/cursor/cursorThemes.ts | 25 +++- src/native/sceneDescription.test.ts | 5 + .../architecture/cursor.md | 18 ++- .../testing/manual-e2e-checklist.md | 2 + 36 files changed, 403 insertions(+), 63 deletions(-) create mode 100644 public/cursors/touch/disk.png create mode 100644 scripts/generate-touch-cursor.mjs diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 3e8003b434..eec7c8c5bd 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -3359,7 +3359,7 @@ impl Compositor { bounds[3].max(dst[1] + dst[3]), ]; }; - let impacts: Vec = + let mut impacts: Vec = plan.impacts.iter().map(|cb| self.make_bind(cb, None, &dummy)).collect(); let sprites = scene_ref @@ -3388,6 +3388,12 @@ impl Compositor { }; let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + for stamp in &plan.touch_trail { + let cb = cursor_sprite_cb( + stamp.placement, [stamp.size_px; 2], hotspot, stamp.alpha, plan.clip, [rw, rh], + ); + impacts.push(self.make_bind(&cb, Some((&view, &view, &view)), &dummy)); + } // Curseur modelise (mode 15) : ce meme sprite extrude, `plan_cursor` en a tire la // pose. La copie de l'image composee au binding 1 (texY), que refracte le cristal de diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index c1072cbf1b..264e3d292f 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -2894,6 +2894,16 @@ impl Compositor { } e.end_encoding(); } + if !plan.touch_trail.is_empty() { + let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + for stamp in &plan.touch_trail { + self.draw_cur_themed( + e, &sprites, kind, stamp.placement, + stamp.size_px, stamp.alpha, plan.clip, None, + ); + } + e.end_encoding(); + } if plan.taps <= 1 { let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; if plan.glass { diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 586cfc19b3..163e4279fd 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -2698,6 +2698,12 @@ impl Compositor { for cb in &plan.impacts { self.draw_solid(cb); } + for stamp in &plan.touch_trail { + self.draw_cur_themed( + &cursor_sprites, cursor_type, stamp.placement, + stamp.size_px, stamp.alpha, plan.clip, None, + ); + } if plan.taps <= 1 { self.draw_cur_themed( &cursor_sprites, diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 4eb981919c..ba7bdeb9a3 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -38,6 +38,8 @@ pub struct CursorTrack { follow_samples: Vec<(f32, f32, f32)>, /// instants de clic (secondes) dans la fenêtre. clicks: Vec, + /// Left-button transitions, including the state inherited before a trimmed offset. + presses: Vec<(f32, bool)>, /// Position BRUTE de chaque clic (même ordre que `clicks`) : le pixel où l'utilisateur a /// réellement cliqué. `smoothed()` la garde telle quelle, alors que la piste lissée passe /// ailleurs à cet instant (cf. `pinned_at`). @@ -158,6 +160,7 @@ impl CursorTrack { follow_samples, clicks, click_points, + presses: Vec::new(), visibility, types, view_cache: Default::default(), @@ -210,6 +213,28 @@ impl CursorTrack { } } let mut visibility = vec![(0.0, visibility_at_offset)]; + let mut presses = Vec::new(); + for s in arr { + let tm = s["timeMs"].as_f64().unwrap_or(-1.0); + if tm < 0.0 || tm > offset_ms + dur_s * 1000.0 { + continue; + } + let down = match s["interactionType"].as_str() { + Some("click" | "double-click") => true, + Some("mouseup") => false, + _ => continue, + }; + presses.push((((tm - offset_ms) / 1000.0) as f32, down)); + } + presses.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Old click-only recordings can show a short press, but cannot describe a drag hold. + if !arr.iter().any(|s| s["interactionType"].as_str() == Some("mouseup")) { + let releases: Vec<_> = presses.iter().enumerate().filter(|(_, (_, down))| *down) + .filter(|(i, (t, _))| presses.get(i + 1).is_none_or(|(next, _)| *next > t + 0.16)) + .map(|(_, (t, _))| (t + 0.16, false)).collect(); + presses.extend(releases); + } + presses.sort_by(|a, b| a.0.total_cmp(&b.0)); let end = offset_ms + dur_s * 1000.0; for s in arr { let tm = s["timeMs"].as_f64().unwrap_or(-1.0); @@ -251,9 +276,38 @@ impl CursorTrack { clicks.sort_by(|a, b| a.partial_cmp(b).unwrap()); types.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); visibility.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - Ok(CursorTrack::new_with_visibility( - samples, clicks, types, visibility, - )) + let mut track = CursorTrack::new_with_visibility(samples, clicks, types, visibility); + track.presses = presses; + Ok(track) + } + + pub fn visible_since(&self, t: f32) -> f32 { + let i = self.visibility.partition_point(|(at, _)| *at <= t); + if i == 0 { + f32::NEG_INFINITY + } else { + self.visibility[i - 1].0 + } + } + + /// Start of the current left-button hold. Position-only sidecars have no hold. + pub fn pressed_since(&self, t: f32) -> Option { + let i = self.presses.partition_point(|(at, _)| *at <= t); + if i == 0 { + return None; + } + let (at, down) = self.presses[i - 1]; + (down && self.visible_at(t)).then_some(at) + } + + #[cfg(test)] + pub(crate) fn touch_fixture(release: f32) -> CursorTrack { + let mut track = CursorTrack::new( + vec![(0.0, 0.2, 0.5), (0.5, 0.4, 0.3), (1.0, 0.8, 0.5), (2.0, 0.8, 0.5)], + vec![0.25], Vec::new(), + ); + track.presses = vec![(0.25, true), (release, false)]; + track } /// Position lissée au temps `t`, pour le suivi auto du zoom. La télémétrie brute est @@ -471,12 +525,7 @@ impl CursorTrack { /// bruts (le bounce est temporel, pas positionnel — ne doit pas suivre le lissage). pub fn smoothed(&self, factor: f32) -> CursorTrack { if self.samples.len() < 2 || factor <= 0.0 { - return CursorTrack::new_with_visibility( - self.samples.clone(), - self.clicks.clone(), - self.types.clone(), - self.visibility.clone(), - ); + return self.clone(); } const STEP_S: f32 = 1.0 / 240.0; let start = self.samples[0].0; @@ -536,6 +585,7 @@ impl CursorTrack { // cliqués restent ceux de la piste brute. CursorTrack { click_points: self.click_points.clone(), + presses: self.presses.clone(), ..CursorTrack::new_with_visibility( samples, self.clicks.clone(), @@ -672,6 +722,45 @@ fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) { mod tests { use super::*; + #[test] + fn touch_press_loads_release_and_survives_trimming_and_smoothing() { + let path = std::env::temp_dir().join(format!("openscreen-touch-{}.json", std::process::id())); + std::fs::write(&path, r#"{"samples":[ + {"timeMs":0,"cx":0.2,"cy":0.5}, + {"timeMs":250,"cx":0.3,"cy":0.4,"interactionType":"click"}, + {"timeMs":600,"cx":0.5,"cy":0.3}, + {"timeMs":750,"cx":1.0,"cy":0.3,"visible":false}, + {"timeMs":900,"cx":0.7,"cy":0.4,"visible":true}, + {"timeMs":1000,"cx":0.8,"cy":0.5,"interactionType":"mouseup"} + ]}"#).unwrap(); + let track = CursorTrack::load(path.to_str().unwrap(), 500.0, 1.0).unwrap(); + std::fs::remove_file(path).unwrap(); + for track in [track.clone(), track.smoothed(0.5), track.smoothed(0.0)] { + assert_eq!(track.pressed_since(0.0), Some(-0.25)); + assert_eq!(track.pressed_since(0.49), Some(-0.25)); + assert_eq!(track.pressed_since(0.3), None, "hidden"); + assert_eq!(track.pressed_since(0.45), Some(-0.25), "hold resumes on re-entry"); + assert!((track.visible_since(0.45) - 0.4).abs() < 1e-5); + assert_eq!(track.pressed_since(0.5), None); + assert_eq!(track.pressed_since(0.1), Some(-0.25), "seek backwards"); + } + } + + #[test] + fn old_click_only_recordings_release_the_touch_press() { + let path = std::env::temp_dir().join(format!("openscreen-touch-old-{}.json", std::process::id())); + std::fs::write(&path, r#"{"samples":[ + {"timeMs":0,"cx":0.2,"cy":0.5,"interactionType":"click"}, + {"timeMs":100,"cx":0.3,"cy":0.5,"interactionType":"click"}, + {"timeMs":1000,"cx":0.8,"cy":0.5} + ]}"#).unwrap(); + let track = CursorTrack::load(path.to_str().unwrap(), 0.0, 1.0).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(track.pressed_since(0.05), Some(0.0)); + assert_eq!(track.pressed_since(0.2), Some(0.1), "earlier release must not cut the next pulse short"); + assert_eq!(track.pressed_since(0.3), None); + } + /// Zone morte du suivi auto : un pointeur qui tremble au centre de la vue ne la bouge pas ; /// un pointeur qui part loin est rattrapé, jusqu'au bord de la zone et pas au-delà. /// Les vues d'une région sont rejouées une fois puis relues : même réponse dans n'importe quel diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index e7410ad558..f62140c291 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -3714,6 +3714,12 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { } } +pub struct CursorStamp { + pub placement: CursorPlacement, + pub size_px: f32, + pub alpha: f32, +} + /// Le curseur, prêt à dessiner : où, à quelle taille, avec quelle traînée. /// /// Extrait de la moitié « dessin » de `compose_frame` pour la même raison que @@ -3721,6 +3727,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { /// entretenir deux copies de ce mapping. Le placement dépend de la coupe source, du zoom, /// du padding et de l'inclinaison — autant d'endroits où deux implémentations dérivent. pub struct CursorPlan { + pub touch_trail: Vec, pub placement: CursorPlacement, /// Placement à `t - trail_frames/FPS`, pour la traînée. `placement` quand il n'y en a pas. pub prev_placement: CursorPlacement, @@ -3790,7 +3797,8 @@ pub fn cursor_alpha( if !scene.map(|s| s.cursor.show).unwrap_or(cfg.cursor) { return 0.0; } - let idle_alpha = track.opacity_at(t, live.cursor_auto_hide); + let touch_held = scene.is_some_and(|s| s.cursor.theme == "touch") && track.pressed_since(t).is_some(); + let idle_alpha = track.opacity_at(t, live.cursor_auto_hide && !touch_held); let zoom_alpha = match scene { Some(s) => { let clock = crate::regions::ScreenClock::new(&s.speed_regions, s.active_clip_index); @@ -3987,19 +3995,25 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option, dst: [f32; 4]| placer.place(cxy, dst); - let placement = place(placer.at(input.t), g.s_dst)?; + // Touch follows the drag continuously; click pinning would hold it at the press origin. + let position = |t| if touch { input.track.at(t) } else { placer.at(t) }; + let placement = place(position(input.t), g.s_dst)?; + let pressed = touch && input.track.pressed_since(input.t).is_some(); let lp = input.live; // `cursor_bounce_scale` est le clickBounce brut (0..5) : au-delà de 1/0.24 ≈ 4.17, le creux // de la pression (0.76) passe sous zéro. Une taille négative retournerait le sprite, donc // plancher à 0 — le curseur disparaît le temps du creux, c'est ce qu'une telle amplitude dit. // Le curseur modélisé n'a pas de rebond d'échelle : le clic le fait toucher le plan à la place. - let bounce = if model3d { + let bounce = if touch { + if pressed { 0.82 } else { 1.0 } + } else if model3d { 1.0 } else { (1.0 + (input.track.bounce(input.t) - 1.0) * lp.cursor_bounce_scale).max(0.0) @@ -4042,7 +4056,7 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Option hover.alpha); + assert_eq!(held.touch_trail.len(), 12); + assert!(hover.touch_trail.is_empty()); + assert!(plan(1.3).touch_trail.is_empty(), "released"); + let repeated = plan(0.7); + for (a, b) in held.touch_trail.iter().zip(&repeated.touch_trail) { + assert_eq!(a.placement.upright_center(), b.placement.upright_center()); + assert_eq!(a.alpha, b.alpha); + assert!(a.alpha < held.alpha && a.size_px < held.size_px); + } + assert!(plan(1.2).touch_trail.is_empty(), "stationary hold"); + let held = crate::cursor::CursorTrack::touch_fixture(4.0); + let mut input = cursor_input(&scene, &cfg, &held, 3.0); + input.live.cursor_auto_hide = true; + assert_eq!(plan_cursor(&fg, &input).unwrap().alpha, 1.0, "a held press is active"); + } + fn cursor_input<'a>( scene: &'a Scene, cfg: &'a crate::config::Cfg, diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts index d0b93adff5..9f18ca1f29 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.test.ts @@ -66,6 +66,16 @@ describe("PipeWireCursorAccumulator", () => { expect(accumulator.toRecordingData().samples[0].interactionType).toBe("move"); }); + it("preserves the release that ends a touch drag", () => { + const accumulator = new PipeWireCursorAccumulator(100); + accumulator.addSample(sample(10, 5, 5, { interactionType: "click" })); + accumulator.addSample(sample(30, 7, 5, { interactionType: "mouseup" })); + expect(accumulator.toRecordingData().samples.map((s) => s.interactionType)).toEqual([ + "click", + "mouseup", + ]); + }); + it("preserves a click the helper read from evdev", () => { const accumulator = new PipeWireCursorAccumulator(100); accumulator.reset(0); diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts index 9a233c7d26..49a7362f7d 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorAccumulator.ts @@ -63,11 +63,11 @@ export type PipeWireHelperEvent = visible: boolean; assetId?: string; asset?: PipeWireCursorAssetPayload; - /** `"click"` on the sample coinciding with a left-button press the + /** `"click"` or `"mouseup"` on a left-button edge 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"; + interactionType?: "move" | "click" | "mouseup"; } | { event: "audio-source"; @@ -181,8 +181,8 @@ export class PipeWireCursorAccumulator { cx: clamp(payload.x / width, 0, 1), cy: clamp(payload.y / height, 0, 1), visible: payload.visible, - // 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 portal never reports a button; the helper tags its left-button edges + // only when it read them 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 7dd222ced0..833c7366f3 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts @@ -24,7 +24,7 @@ import type { CursorRecordingSession } from "./session"; * * `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 + * root:input. When it can, button edges are tagged "click" and "mouseup"; 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 diff --git a/electron/native/pipewire-capture/src/events.rs b/electron/native/pipewire-capture/src/events.rs index 9bc4d4c7a4..524cbe9ed1 100644 --- a/electron/native/pipewire-capture/src/events.rs +++ b/electron/native/pipewire-capture/src/events.rs @@ -100,8 +100,8 @@ 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 + /// `"click"` or `"mouseup"` on a left-button edge read + /// from evdev (see `input.rs`), absent on a position-only sample. 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")] diff --git a/electron/native/pipewire-capture/src/input.rs b/electron/native/pipewire-capture/src/input.rs index a1d41c90de..34371c2f24 100644 --- a/electron/native/pipewire-capture/src/input.rs +++ b/electron/native/pipewire-capture/src/input.rs @@ -12,7 +12,7 @@ //! //! 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 +//! `BTN_LEFT`, and only their press and release edges; it never reads, stores, or forwards any //! other key code. Enumerating the devices does briefly open each readable //! `/dev/input/event*` node to inspect its capability bits, but any node that //! does not advertise `BTN_LEFT` is dropped immediately, without a single event @@ -40,13 +40,16 @@ const DISABLE_ENV: &str = "OPENSCREEN_DISABLE_CLICK_CAPTURE"; /// just plugged in and costs a cheap directory walk. const RESCAN_INTERVAL: Duration = Duration::from_secs(3); -/// 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 +/// Only left-button edges are forwarded; autorepeat and keyboard input are ignored. +pub fn left_button_state(event_type: EventType, code: u16, value: i32) -> Option { + if event_type != EventType::KEY || code != KeyCode::BTN_LEFT.0 { + return None; + } + match value { + 0 => Some(false), + 1 => Some(true), + _ => None, + } } /// What [`spawn_readers`] settled on — the caller uses it to tell the log why @@ -134,7 +137,7 @@ fn device_reports_left_button(device: &Device) -> bool { } /// Blocks reading `device`, forwarding one `PointerButton` message per left-button -/// press, each stamped with the press time so a click is not backdated to the +/// edge, each stamped with the event time so a click is not backdated to the /// next cursor sample. Returns when the device fails terminally (e.g. unplugged) /// or the loop's channel has closed, so the thread cannot outlive the recording /// it serves. A transient `EINTR` is retried, not mistaken for an unplug. @@ -165,11 +168,11 @@ fn read_device( } }; for event in events { - if is_left_button_press(event.event_type(), event.code(), event.value()) - && sender.send(Message::PointerButton(timestamp_ms())).is_err() - { - release(); - return; + if let Some(down) = left_button_state(event.event_type(), event.code(), event.value()) { + if sender.send(Message::PointerButton(timestamp_ms(), down)).is_err() { + release(); + return; + } } } } @@ -184,28 +187,28 @@ mod tests { #[test] fn a_left_button_press_is_a_click() { - assert!(is_left_button_press(EventType::KEY, BTN_LEFT, 1)); + assert_eq!(left_button_state(EventType::KEY, BTN_LEFT, 1), Some(true)); } #[test] - fn a_left_button_release_is_not() { - assert!(!is_left_button_press(EventType::KEY, BTN_LEFT, 0)); + fn a_left_button_release_is_forwarded() { + assert_eq!(left_button_state(EventType::KEY, BTN_LEFT, 0), Some(false)); } #[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)); + assert_eq!(left_button_state(EventType::KEY, BTN_LEFT, 2), None); } #[test] fn a_right_button_press_is_not_a_left_click() { - assert!(!is_left_button_press(EventType::KEY, BTN_RIGHT, 1)); + assert_eq!(left_button_state(EventType::KEY, BTN_RIGHT, 1), None); } #[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)); + assert_eq!(left_button_state(EventType::RELATIVE, BTN_LEFT, 1), None); } } diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 685b64d606..e179b9d011 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -23,7 +23,7 @@ //! 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 +//! helper reads left-button edges and tags samples "click" or "mouseup"; 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). @@ -223,12 +223,12 @@ struct AudioSourceConfig { enum Message { Portal(Box>), Stream(StreamEvent), - /// A left mouse-button press observed on evdev, carrying the press time in + /// A left mouse-button edge observed on evdev, carrying the event time in /// milliseconds so the emitted click sample is stamped when it happened /// rather than at the next throttled sample. See [`input`] for why this is /// the only way to see a button on Wayland, and for its permission and /// privacy model. - PointerButton(u64), + PointerButton(u64, bool), /// A pointer reader thread stopped on a device error (typically an unplug); /// the string is the OS error. Surfaced as a warning so click capture going /// quiet mid-recording is not silent. @@ -777,17 +777,17 @@ fn run( match receiver.recv_timeout(config.tick) { Ok(Message::Stop) => break, - // Emit a click sample straight away at the current cursor position, - // stamped with the press time the reader captured: immediate so it is + // Emit each button edge straight away at the current cursor position, + // stamped with the event time the reader captured: immediate so it is // not backdated to the next throttled sample, and one sample per press // so a rapid double-click reads as two clicks rather than collapsing // into one. Counted only if the press happened at or after streaming // began (see `streaming_since`): a press while the picker is still up — // the click on its "Share" button — is older, so it is dropped even if // its message is delivered after the stream-state one. - Ok(Message::PointerButton(press_ms)) => { + Ok(Message::PointerButton(press_ms, down)) => { if streaming_since.is_some_and(|since| press_ms >= since) { - emit_sample(emitter, &cursor, content_rect(&capture, size), &mut pending_asset, Some(press_ms)); + emit_sample(emitter, &cursor, content_rect(&capture, size), &mut pending_asset, Some((press_ms, down))); } } @@ -1536,8 +1536,8 @@ fn content_rect(capture: &Option, size: Option<(i32, i32)>) -> Option( @@ -1545,7 +1545,7 @@ fn emit_sample( cursor: &Option, content: Option, pending_asset: &mut Option, - click: Option, + click: Option<(u64, bool)>, ) { let (Some(state), Some(content)) = (cursor, content) else { return; @@ -1559,7 +1559,7 @@ fn emit_sample( // down, not up to a sample interval later; the position is the latest known, // which at the sample cadence has not moved enough to be wrong. let (timestamp, interaction_type) = match click { - Some(press_ms) => (press_ms, Some("click".to_owned())), + Some((at, down)) => (at, Some(if down { "click" } else { "mouseup" }.to_owned())), None => (timestamp_ms(), None), }; let _ = emitter.emit(&Event::CursorSample { @@ -1643,6 +1643,20 @@ fn resolve_microphone_node(label: &str, sources: &[shim::AudioSourceInfo]) -> Op mod cursor_sample_tests { use super::*; + #[test] + fn left_button_edges_keep_their_time_and_state() { + for (down, expected) in [(true, "click"), (false, "mouseup")] { + let mut buffer = Vec::new(); + let mut emitter = Emitter::new(&mut buffer, false); + emit_sample(&mut emitter, &Some(CursorState { x: 10, y: 10, asset_id: None }), + Some(shim::CropRect { x: 0, y: 0, width: 100, height: 100 }), + &mut None, Some((123, down))); + let value: serde_json::Value = serde_json::from_slice(&buffer).unwrap(); + assert_eq!(value["timestampMs"], 123); + assert_eq!(value["interactionType"], expected); + } + } + fn sample_json(cursor: (i32, i32), content: Option) -> serde_json::Value { let mut buffer = Vec::new(); let mut emitter = Emitter::new(&mut buffer, false); diff --git a/public/cursors/touch/disk.png b/public/cursors/touch/disk.png new file mode 100644 index 0000000000000000000000000000000000000000..470356ac5879ba2f0a83049a8224eaacb57796c6 GIT binary patch literal 7323 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 B;CTQ5 literal 0 HcmV?d00001 diff --git a/scripts/generate-touch-cursor.mjs b/scripts/generate-touch-cursor.mjs new file mode 100644 index 0000000000..79b770fba5 --- /dev/null +++ b/scripts/generate-touch-cursor.mjs @@ -0,0 +1,26 @@ +// Run with node scripts/generate-touch-cursor.mjs. The disk is original OpenScreen artwork. +import { mkdir, writeFile } from "node:fs/promises"; +import { chromium } from "playwright"; + +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage(); + const data = await page.evaluate(() => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 128; + const ctx = canvas.getContext("2d"); + ctx.beginPath(); + ctx.arc(64, 64, 58, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.6)"; + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = "rgba(32,40,48,0.7)"; + ctx.stroke(); + return canvas.toDataURL("image/png").split(",")[1]; + }); + const out = new URL("../public/cursors/touch/", import.meta.url); + await mkdir(out, { recursive: true }); + await writeFile(new URL("disk.png", out), Buffer.from(data, "base64")); +} finally { + await browser.close(); +} diff --git a/src/components/ai-edition/CursorPane.levels.test.tsx b/src/components/ai-edition/CursorPane.levels.test.tsx index bc714e2f7b..8748e5a33a 100644 --- a/src/components/ai-edition/CursorPane.levels.test.tsx +++ b/src/components/ai-edition/CursorPane.levels.test.tsx @@ -43,6 +43,22 @@ function renderWithCursor(legacyEditor: Record) { const pressed = (group: string) => screen.getByRole("group", { name: group }).querySelectorAll('button[aria-pressed="true"]'); +describe("CursorPane touch style", () => { + it("selects and persists Touch, keeping it flat when the stored 3D option is on", async () => { + renderWithCursor({ cursorTheme: "default", cursorModel3d: true }); + fireEvent.click(screen.getByRole("button", { name: "Touch" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Touch" }).getAttribute("aria-pressed")).toBe( + "true", + ), + ); + expect(useProjectStore.getState().document?.legacyEditor?.cursorTheme).toBe("touch"); + const model = screen.getByRole("button", { name: "3D cursor" }); + expect(model.getAttribute("aria-pressed")).toBe("false"); + expect(model.hasAttribute("disabled")).toBe(true); + }); +}); + describe("CursorPane named levels", () => { it("names the click bounce instead of showing a number", () => { renderWithCursor({ cursorClickBounce: 0 }); diff --git a/src/components/ai-edition/CursorPane.preview.test.tsx b/src/components/ai-edition/CursorPane.preview.test.tsx index 0aa86791fd..daebf958dd 100644 --- a/src/components/ai-edition/CursorPane.preview.test.tsx +++ b/src/components/ai-edition/CursorPane.preview.test.tsx @@ -38,7 +38,7 @@ afterEach(() => { describe("CursorPane theme picker", () => { it("shows the original cursor themes beside the default", () => { - expect(CURSOR_THEMES).toHaveLength(6); + expect(CURSOR_THEMES).toHaveLength(7); render( diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 1e0c674108..9a195ca672 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -120,6 +120,7 @@ import { DEFAULT_CURSOR_SPRITES, DEFAULT_CURSOR_THEME_ID, resolveCursorSprites, + TOUCH_CURSOR_THEME_ID, themePickerPreviewAssets, } from "@/lib/cursor/cursorThemes"; import { gradientSeedColor, oneColorGradient } from "@/lib/gradientBuilder"; @@ -3679,7 +3680,10 @@ export function CursorPane() { () => [null, ...CURSOR_THEMES].map((theme) => ({ id: theme?.id ?? DEFAULT_CURSOR_THEME_ID, - name: theme?.name ?? ts("cursor.themeDefault"), + name: + theme?.id === TOUCH_CURSOR_THEME_ID + ? ts("cursor.themeTouch") + : (theme?.name ?? ts("cursor.themeDefault")), previewUrl: safeAssetUrl( themePickerPreviewAssets(theme).arrow || DEFAULT_CURSOR_SPRITES.arrow.assetPath, ), @@ -3696,6 +3700,7 @@ export function CursorPane() { const clickSound = useClickSound(); // Each kind the video shows, pictured by the first of its states it shows. const recordedKinds = CURSOR_KIND_IDS.flatMap((kind) => { + if (settings.cursorTheme === TOUCH_CURSOR_THEME_ID) return []; const type = CURSOR_KINDS[kind].find((state) => recordedTypes?.has(state)); return type ? [{ kind, type }] : []; }); @@ -3785,8 +3790,8 @@ export function CursorPane() { { void set({ cursor: { model3d: v } }); if (isNativeCompositorActive()) { diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 0810409f12..83dbf283d2 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -420,6 +420,7 @@ "title": "المؤشر", "show": "إظهار المؤشر", "themeDefault": "افتراضي", + "themeTouch": "لمس", "types": "أنواع المؤشر", "typeArrow": "سهم", "typePointer": "يد", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 80eb9b2687..95f5d74ba7 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -420,6 +420,7 @@ "title": "Kurzor", "show": "Zobrazit kurzor", "themeDefault": "Výchozí", + "themeTouch": "Dotyk", "types": "Typy kurzoru", "typeArrow": "Šipka", "typePointer": "Ruka", diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 715d3c9bb9..1ca7d9b03a 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -420,6 +420,7 @@ "title": "Cursor", "show": "Cursor anzeigen", "themeDefault": "Standard", + "themeTouch": "Touch", "types": "Cursortypen", "typeArrow": "Pfeil", "typePointer": "Hand", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index be8b13a694..fed04add45 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -420,6 +420,7 @@ "title": "Cursor", "show": "Show cursor", "themeDefault": "Default", + "themeTouch": "Touch", "types": "Cursor types", "typeArrow": "Arrow", "typePointer": "Hand", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index f9efcfab37..42aad52ede 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -420,6 +420,7 @@ "title": "Cursor", "show": "Mostrar cursor", "themeDefault": "Predeterminado", + "themeTouch": "Táctil", "types": "Tipos de cursor", "typeArrow": "Flecha", "typePointer": "Mano", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index cbd0536996..74ee2af905 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -420,6 +420,7 @@ "title": "Curseur", "show": "Afficher le curseur", "themeDefault": "Par défaut", + "themeTouch": "Tactile", "types": "Types de curseur", "typeArrow": "Flèche", "typePointer": "Main", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index fd37547e2f..d75e9b0d22 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -420,6 +420,7 @@ "title": "Cursore", "show": "Mostra cursore", "themeDefault": "Predefinito", + "themeTouch": "Tocco", "types": "Tipi di cursore", "typeArrow": "Freccia", "typePointer": "Mano", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 7349992049..87612309bf 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -420,6 +420,7 @@ "title": "カーソル", "show": "カーソルを表示", "themeDefault": "デフォルト", + "themeTouch": "タッチ", "types": "カーソルの種類", "typeArrow": "矢印", "typePointer": "手", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index c464581da8..f8a3b2ce1b 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -420,6 +420,7 @@ "title": "커서", "show": "커서 표시", "themeDefault": "기본", + "themeTouch": "터치", "types": "커서 종류", "typeArrow": "화살표", "typePointer": "손", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 53a4c89de0..f6634f58da 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -420,6 +420,7 @@ "title": "Cursor", "show": "Mostrar cursor", "themeDefault": "Padrão", + "themeTouch": "Toque", "types": "Tipos de cursor", "typeArrow": "Seta", "typePointer": "Mão", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 08c1a76c9d..7cb713bb85 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -420,6 +420,7 @@ "title": "Курсор", "show": "Показывать курсор", "themeDefault": "По умолчанию", + "themeTouch": "Касание", "types": "Типы курсора", "typeArrow": "Стрелка", "typePointer": "Рука", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 2f3fd2c676..ce5ea6d47a 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -420,6 +420,7 @@ "title": "İmleç", "show": "İmleci Göster", "themeDefault": "Varsayılan", + "themeTouch": "Dokunma", "types": "İmleç türleri", "typeArrow": "Ok", "typePointer": "El", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index ece372040f..8348221cb7 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -420,6 +420,7 @@ "title": "Con trỏ", "show": "Hiện con trỏ", "themeDefault": "Mặc định", + "themeTouch": "Chạm", "types": "Loại con trỏ", "typeArrow": "Mũi tên", "typePointer": "Bàn tay", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 3aa4b4b485..efcd46f6d4 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -420,6 +420,7 @@ "title": "光标", "show": "显示光标", "themeDefault": "默认", + "themeTouch": "触控", "types": "光标类型", "typeArrow": "箭头", "typePointer": "手形", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index f33f5439d9..b39ade1902 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -420,6 +420,7 @@ "title": "游標", "show": "顯示游標", "themeDefault": "預設", + "themeTouch": "觸控", "types": "游標類型", "typeArrow": "箭頭", "typePointer": "手形", diff --git a/src/lib/cursor/cursorThemes.test.ts b/src/lib/cursor/cursorThemes.test.ts index 63707c75e0..b9eaf2d5c0 100644 --- a/src/lib/cursor/cursorThemes.test.ts +++ b/src/lib/cursor/cursorThemes.test.ts @@ -48,7 +48,9 @@ describe("normalizeCursorThemeId", () => { expect(normalizeCursorThemeId(42)).toBe(DEFAULT_CURSOR_THEME_ID); }); - it.each(CURSOR_THEMES)("keeps the original $name theme", (theme) => { + it.each( + CURSOR_THEMES.filter((theme) => theme.id !== "touch"), + )("keeps the original $name theme", (theme) => { expect(normalizeCursorThemeId(theme.id)).toBe(theme.id); const sprites = resolveCursorSprites(theme.id); expect(sprites.arrow.assetPath).toBe(theme.assets.arrow?.assetPath); @@ -67,7 +69,9 @@ describe("normalizeCursorThemeId", () => { // The compositor models these two states itself (`crates/compositor/src/sculpt.rs`, which // knows the same names): in 3D the scene names the model, and the sprite stays the flat art. - it.each(CURSOR_THEMES)("names the sculpted 3D arrow and hand of $name", (theme) => { + it.each( + CURSOR_THEMES.filter((theme) => theme.id !== "touch"), + )("names the sculpted 3D arrow and hand of $name", (theme) => { const flat = resolveCursorSprites(theme.id); const sprites = resolveCursorSprites(theme.id, [], true); for (const state of ["arrow", "pointer"] as const) { @@ -213,3 +217,22 @@ describe("themePickerPreviewAssets", () => { } }); }); + +describe("touch theme", () => { + it("persists and draws the same centered disk for every recorded shape, even with 3D on", () => { + expect(normalizeCursorThemeId("touch")).toBe("touch"); + for (const model3d of [false, true]) { + for (const sprite of Object.values(resolveCursorSprites("touch", ["text"], model3d))) { + expect(sprite).toEqual({ + assetPath: "cursors/touch/disk.png", + hotspotX: 0.5, + hotspotY: 0.5, + }); + } + } + const png = readFileSync(join(process.cwd(), "public/cursors/touch/disk.png")); + expect(png.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); + expect(png.readUInt32BE(16)).toBe(128); + expect(png.readUInt32BE(20)).toBe(128); + }); +}); diff --git a/src/lib/cursor/cursorThemes.ts b/src/lib/cursor/cursorThemes.ts index 88bd6a2606..5205168f11 100644 --- a/src/lib/cursor/cursorThemes.ts +++ b/src/lib/cursor/cursorThemes.ts @@ -25,7 +25,7 @@ export interface CursorThemeAsset { export interface CursorTheme { id: string; - /** Display label. Proper nouns, so not run through i18n. */ + /** Display label. Pack names are proper nouns; Touch is translated in the picker. */ name: string; /** Attribution / origin for the artwork. */ source?: string; @@ -38,6 +38,7 @@ export interface CursorTheme { /** Sentinel id for the built-in cursor art (no theme override). */ export const DEFAULT_CURSOR_THEME_ID = "default"; +export const TOUCH_CURSOR_THEME_ID = "touch"; /** * One sprite as the native compositor consumes it: a path under the public asset root, @@ -134,10 +135,11 @@ export function readCursorAsArrow( } /** - * Bundled cursor themes. These six packs are original OpenScreen artwork. Their raster + * Bundled cursor themes. The six packs and Touch are original OpenScreen artwork. Pack raster * masters live in design/cursors/ and are prepared by scripts/generate-original-cursor-themes.mjs, * except Pixel Candy's pixel art, drawn as a grid in scripts/generate-pixel-candy-voxels.mjs, and * Glass Lens, whose picker PNGs scripts/generate-glass-lens-cursor.mjs draws from its 3D model. + * Touch's disk is generated by scripts/generate-touch-cursor.mjs. * The former Sweezy packs were removed * because their terms forbid redistribution without written permission. * @@ -284,6 +286,19 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ }, }, }, + { + id: TOUCH_CURSOR_THEME_ID, + name: "Touch", + assets: { + arrow: { + assetPath: "cursors/touch/disk.png", + width: 32, + height: 32, + hotspotX: 16, + hotspotY: 16, + }, + }, + }, ]; /** All selectable theme ids, including the built-in default. */ @@ -342,6 +357,12 @@ export function resolveCursorSprites( model3d = false, ): Record { const sprites = { ...DEFAULT_CURSOR_SPRITES }; + if (themeId === TOUCH_CURSOR_THEME_ID) { + for (const type of Object.keys(sprites) as NativeCursorType[]) { + sprites[type] = { assetPath: "cursors/touch/disk.png", hotspotX: 0.5, hotspotY: 0.5 }; + } + return sprites; + } for (const [type, asset] of Object.entries(getCursorTheme(themeId)?.assets ?? {})) { if (asset.width <= 0 || asset.height <= 0) { continue; diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 761eadcb29..b8b7f57844 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1310,6 +1310,11 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(legacy).layout.webcamAnchor).toBe("bottom-left"); }); + it("carries Touch from persisted settings into the shared preview and export scene", () => { + const scene = buildSceneDescription(makeDoc({ legacyEditor: { cursorTheme: "touch" } })); + expect(scene.cursor.theme).toBe("touch"); + }); + it("keeps the flat cursor for an older project and carries the 3D cursor switch", () => { expect(buildSceneDescription(makeDoc({ legacyEditor: {} })).cursor.model3d).toBe(false); const on = makeDoc({ legacyEditor: { cursorModel3d: true } }); diff --git a/technical-documentation/architecture/cursor.md b/technical-documentation/architecture/cursor.md index bd8db12a86..bda62d5d6f 100644 --- a/technical-documentation/architecture/cursor.md +++ b/technical-documentation/architecture/cursor.md @@ -28,13 +28,29 @@ Glass cursors read the picture under them. When the cursor is the Prism Glow cry The cursor settings pane is `CursorPane` in `src/components/ai-edition/RightPanes.tsx`; the current v4 inspector exposes it through the cursor facet in `src/components/ai-edition/v4/FloatingInspector.tsx:57-63,1073`. It controls showing the cursor, clipping it to the canvas, theme, size, smoothing, motion blur, and click bounce. `RightPanes.tsx:1592-1692` binds those controls to editor settings and forwards the rendering parameters to the native compositor. Preview and export both render through that compositor, so a change shows in both. +## Touch style + +The Touch theme uses one centered translucent disk for every recorded cursor shape. It stays flat +when the 3D option is enabled. A recorded left-button press contracts and darkens the disk until +`mouseup`; a moving held press leaves a short fading trail. Both preview and export consume these +stamps from `plan_cursor`, including the same crop, zoom, tilt and clip placement as other styles. +The trail samples the recent path at the requested time, so seeking does not depend on previously +rendered frames. Older click-only sidecars show a short press pulse; without release events they +cannot describe a held drag. New Wayland recordings also carry release events when the existing +evdev click reader has input permission. Without that permission, Wayland still provides position +only; touchpad taps synthesized by libinput are not captured. + ## Auto-follow Cursor telemetry also drives camera focus for auto-follow zooms. `src/lib/zoomMath/cursorFollowUtils.ts` interpolates the cursor at content time and applies distance-adaptive, frame-rate-independent smoothing. The zoom-region utilities use that focus for preview, while the export frame renderer uses the corresponding focus during export. The native compositor keeps a raw track for cursor placement and derives a separately smoothed follow track (`crates/compositor/src/cursor.rs:16-24,126-136`), preventing camera motion from changing the cursor's actual recorded position. ## Bundled assets -No themed cursor pack ships today. The packs that used to sit under `public/cursors//` came from sweezy-cursors.com, whose terms forbid redistributing them, and were removed. A pack added later goes there as arrow and pointer PNGs registered in `CURSOR_THEMES` (`src/lib/cursor/cursorThemes.ts`), under a licence recorded in `THIRD-PARTY-NOTICES.md`; the editor only shows the theme picker once one exists. The built-in native replacement SVG set is under `src/assets/cursors/`; `DEFAULT_CURSOR_SPRITES` in `src/lib/cursor/cursorThemes.ts` maps each captured cursor type to its rasterized asset and hotspot. +Bundled styles live under `public/cursors//` and are registered in `CURSOR_THEMES` +(`src/lib/cursor/cursorThemes.ts`). The original OpenScreen packs have separate arrow and pointer +art; Touch uses one disk for all states. The former Sweezy packs were removed because their terms +forbid redistribution. The built-in native replacement SVG set is under `src/assets/cursors/`; +`DEFAULT_CURSOR_SPRITES` maps each captured cursor type to its rasterized asset and hotspot. The same built-in art also exists as PNGs under `public/cursors/default/`, generated from those SVGs by `scripts/generate-default-cursor-sprites.mjs`. The native compositor decodes png/jpeg from a real path and cannot read the SVGs, which exist only as bundler URLs in the renderer — so without the PNG set it had no default art and drew a placeholder dot-and-ring instead of a pointer. Regenerate rather than hand-editing: the script also emits the `DEFAULT_CURSOR_SPRITES` hotspot table, which would otherwise drift from the images. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 2bd38f8a9a..b9f1e87b0a 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -1006,3 +1006,5 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-27 | Dev v2.1.2, `codex/timeline-dispositions`, review follow-up to `5b76530fb`; MSVC Rust test binary rebuilt from this tree | Windows 11 | **Partial — automated regression slice only** | PR #1404 review fixes: 10 native layout tests passed, including new hard-cut motion-blur isolation and preservation of positive-duration animation trails. React regressions cover selecting only the last passage at/after timeline end and closing/clearing the layout picker in media mode, without a document, or without clips. Agent tests reject no-op layout writes; workbench verifies reports only when no later mutation follows. Full JavaScript suite: 5,069 passed, 5 skipped; 19 targeted workbench tests and app/test/workbench typechecks pass. **Skipped:** desktop computer-use was not resumed after the user's earlier Escape; no fresh GUI preview, export pixel, capture, device, tray, macOS/Linux runtime or full checklist pass. The running app's native addon was not replaced during this review follow-up. | | 2026-10-27 | Dev v2.1.2, `codex/timeline-dispositions` @ `ee1eeb9f2` + zoom-framing/camera-roundness fix; Windows compositor rebuilt from this tree | Windows 11, D3D11 / NVENC | **Partial — native export regression slice passed** | Recreated the latest saved project’s 1298×1920 phone-frame scene with synthetic screen/camera media: top/bottom → PiP at 1.129 s during a 1.8× zoom, then screen-only at 2.163 s. Measured 12 frames from the native 146-frame, 60 fps MP4: footage width stays 994 px throughout (before: grew from 994 to 1298 px and returned). During camera disappearance, rounded-mask fill stays 88.3–89.9% (before: 97.5% at 2.23 s, visibly squaring); camera vanishes at 2.30 s. Native regressions also cover appearance, multiple radii, later independent PiP zoom overflow, connected pans, speed and trim gates; scene tests cover trimmed history and clip isolation. Full JavaScript suite: 5,082 passed, 5 skipped; 479 native tests passed, with the same playback-at-end baseline failure excluded after its earlier reproduction on pristine `27d9be27b`. **Skipped:** desktop computer-use was not resumed after the user’s earlier Escape; GUI preview/playback, real capture/webcam/tray, tilted/3D pixel exports, macOS/Linux runtime and the remainder of the release checklist. The export uses the shared preview/export geometry planner; this is not release-promotion QA. | | 2026-10-17 | Camera continuity fix, first tested on `v2.0.0` + local camera patch; ported to `main` @ `763cfedc` | macOS 27.0.1, Apple Silicon; Logitech C922 and built-in FaceTime camera | **Partial — camera capture, preview and MP4 export** | Computer-use capture-to-editor checks on the local build: 128 s VP8 webcam take, 3,828 frames, maximum PTS gap 68 ms; final installed-build 64 s take, 1,932 frames, maximum gap 66 ms. Before the codec change, H.264 webcam capture produced only 13 frames in 56 s. Native VP8 decoding presents the camera in preview and MP4 export. Autofocus untouched. Upstream port: focused recorder/WebM tests, both TypeScript checks and 4,909 unit tests pass (2 skipped). Not covered: desktop smoke test of the port on current main, Windows/Linux physical capture, and the rest of the checklist. A separate held-screen preview issue is addressed by upstream preview clock commits, outside this camera patch. | + +| 2026-10-27 | Local `fix-openscreen-1366`, Touch style implementation | Linux, headless compute host | **Skipped, native desktop pass unavailable** | TypeScript UI/state tests and native cursor/input/geometry tests run separately. No computer-use MCP or built compositor addon is available; the native build is blocked by missing shared FFmpeg headers/libs. **Skipped:** real Wayland input and capture, desktop preview, MP4/GIF export frames, Windows/macOS runtime and the remaining checklist. This is not release-promotion QA. | From 006c6a42ed67fdfa207d41931659c2b811a6fdaa Mon Sep 17 00:00:00 2001 From: Taylor Hiott <216775752+TayHCode@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:52:10 +0000 Subject: [PATCH 2/2] fix: track pointer holds per device and release on loss --- electron/native/pipewire-capture/src/input.rs | 36 +++++++++++++-- electron/native/pipewire-capture/src/main.rs | 45 ++++++++++++++++--- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/electron/native/pipewire-capture/src/input.rs b/electron/native/pipewire-capture/src/input.rs index 34371c2f24..a08a12652c 100644 --- a/electron/native/pipewire-capture/src/input.rs +++ b/electron/native/pipewire-capture/src/input.rs @@ -22,7 +22,7 @@ use std::collections::HashSet; use std::io::ErrorKind; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex}; use std::thread; @@ -162,14 +162,17 @@ fn read_device( // than ending silently, so click capture going quiet mid-recording // is answerable from the log; the watcher re-adopts it on a replug. Err(err) => { + report_device_lost(&sender, &path, err.to_string()); release(); - let _ = sender.send(Message::PointerDeviceLost(err.to_string())); return; } }; for event in events { if let Some(down) = left_button_state(event.event_type(), event.code(), event.value()) { - if sender.send(Message::PointerButton(timestamp_ms(), down)).is_err() { + if sender + .send(Message::PointerButton(path.clone(), timestamp_ms(), down)) + .is_err() + { release(); return; } @@ -178,10 +181,37 @@ fn read_device( } } +fn report_device_lost(sender: &Sender, path: &Path, reason: String) { + // The recording loop ignores releases for devices that are not held. + // Send before freeing the path so a replug cannot overtake this release. + let _ = sender.send(Message::PointerButton(path.to_path_buf(), timestamp_ms(), false)); + let _ = sender.send(Message::PointerDeviceLost(reason)); +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn device_loss_releases_its_button_before_reporting_the_error() { + let (sender, receiver) = std::sync::mpsc::channel(); + let path = PathBuf::from("/dev/input/event-test"); + sender.send(Message::PointerButton(path.clone(), 123, true)).unwrap(); + report_device_lost(&sender, &path, "unplugged".into()); + assert!(matches!( + receiver.try_recv().unwrap(), + Message::PointerButton(device, 123, true) if device == path + )); + assert!(matches!( + receiver.try_recv().unwrap(), + Message::PointerButton(device, _, false) if device == path + )); + assert!(matches!( + receiver.try_recv().unwrap(), + Message::PointerDeviceLost(reason) if reason == "unplugged" + )); + } + const BTN_LEFT: u16 = KeyCode::BTN_LEFT.0; const BTN_RIGHT: u16 = KeyCode::BTN_RIGHT.0; diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index e179b9d011..688dc2a215 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -223,12 +223,12 @@ struct AudioSourceConfig { enum Message { Portal(Box>), Stream(StreamEvent), - /// A left mouse-button edge observed on evdev, carrying the event time in - /// milliseconds so the emitted click sample is stamped when it happened - /// rather than at the next throttled sample. See [`input`] for why this is + /// A left mouse-button edge observed on evdev, carrying its device path + /// and time in milliseconds so the emitted click sample is stamped when + /// it happened rather than at the next throttled sample. See [`input`] for why this is /// the only way to see a button on Wayland, and for its permission and /// privacy model. - PointerButton(u64, bool), + PointerButton(PathBuf, u64, bool), /// A pointer reader thread stopped on a device error (typically an unplug); /// the string is the OS error. Surfaced as a warning so click capture going /// quiet mid-recording is not silent. @@ -672,6 +672,7 @@ fn run( // picker has been answered. let mut armed = false; let mut cursor: Option = None; + let mut held_pointer_devices = HashSet::new(); let mut known_assets: HashSet = HashSet::new(); let mut pending_asset: Option = None; // Wall-clock ms at which the pw_stream last reached `streaming` (mutter began @@ -785,8 +786,9 @@ fn run( // began (see `streaming_since`): a press while the picker is still up — // the click on its "Share" button — is older, so it is dropped even if // its message is delivered after the stream-state one. - Ok(Message::PointerButton(press_ms, down)) => { - if streaming_since.is_some_and(|since| press_ms >= since) { + Ok(Message::PointerButton(device, press_ms, down)) => { + let edge = pointer_button_edge(&mut held_pointer_devices, device, down); + if streaming_since.is_some_and(|since| press_ms >= since) && edge.is_some() { emit_sample(emitter, &cursor, content_rect(&capture, size), &mut pending_asset, Some((press_ms, down))); } } @@ -1525,6 +1527,37 @@ fn content_rect(capture: &Option, size: Option<(i32, i32)>) -> Option, device: PathBuf, down: bool) -> Option { + if down { + held.insert(device); + Some(true) + } else if held.remove(&device) && held.is_empty() { + Some(false) + } else { + None + } +} + +#[cfg(test)] +mod pointer_button_tests { + use super::*; + + #[test] + fn one_device_release_does_not_end_another_devices_hold() { + let mut held = HashSet::new(); + let mouse = PathBuf::from("/dev/input/mouse"); + let touchpad = PathBuf::from("/dev/input/touchpad"); + assert_eq!(pointer_button_edge(&mut held, mouse.clone(), true), Some(true)); + assert_eq!( + pointer_button_edge(&mut held, touchpad.clone(), true), + Some(true) + ); + assert_eq!(pointer_button_edge(&mut held, mouse.clone(), false), None); + assert_eq!(pointer_button_edge(&mut held, touchpad, false), Some(false)); + assert_eq!(pointer_button_edge(&mut held, mouse, false), None); + } +} + /// Emits one cursor sample, positioned inside `content`. /// /// `content` is the sub-rectangle of the stream the recording actually shows —