Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3359,7 +3359,7 @@ impl Compositor {
bounds[3].max(dst[1] + dst[3]),
];
};
let impacts: Vec<LayerBind> =
let mut impacts: Vec<LayerBind> =
plan.impacts.iter().map(|cb| self.make_bind(cb, None, &dummy)).collect();

let sprites = scene_ref
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
107 changes: 98 additions & 9 deletions crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub struct CursorTrack {
follow_samples: Vec<(f32, f32, f32)>,
/// instants de clic (secondes) dans la fenêtre.
clicks: Vec<f32>,
/// 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`).
Expand Down Expand Up @@ -158,6 +160,7 @@ impl CursorTrack {
follow_samples,
clicks,
click_points,
presses: Vec::new(),
visibility,
types,
view_cache: Default::default(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<f32> {
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
85 changes: 79 additions & 6 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3714,13 +3714,20 @@ 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
/// `plan_frame` : deux backends qui doivent poser le curseur au pixel près ne peuvent pas
/// 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<CursorStamp>,
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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3987,19 +3995,25 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP

let cursor_type = input.track.type_at(input.t);
let sprite = modelled_sprite(input.scene, cursor_type);
let model_sprite = sprite.filter(|_| input.live.cursor_model3d);
let touch = input.scene.is_some_and(|s| s.cursor.theme == "touch");
let model_sprite = sprite.filter(|_| input.live.cursor_model3d && !touch);
let model3d = model_sprite.is_some();
let placer = CursorPlacer::new(g, input, model3d);
let (s_px, tilt, quad_center_px) = (placer.s_px, placer.tilt, placer.quad_center_px);
let place = |cxy: Option<(f32, f32)>, 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)
Expand Down Expand Up @@ -4042,7 +4056,7 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP
trail_from(input.t - 1.0 / FPS)
};
(taps, prev)
} else if blur01 <= 0.001 {
} else if touch || blur01 <= 0.001 {
(1, placement)
} else {
// Intervalle d'obturateur court, borné à 1 frame (100% blur = 1 frame d'exposition)
Expand Down Expand Up @@ -4107,14 +4121,38 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP
Vec::new()
};

let mut touch_trail = Vec::new();
if let Some(start) = input.track.pressed_since(input.t).filter(|_| touch) {
for k in (1..=12).rev() {
let age = k as f32 * 0.015;
let t = input.t - age;
if t < start.max(input.track.visible_since(input.t)) || !input.track.visible_at(t) {
continue;
}
let Some(previous) = place(input.track.at(t), g.s_dst) else {
continue;
};
let a = previous.upright_center();
let b = placement.upright_center();
if ((a[0] - b[0]) * rw).hypot((a[1] - b[1]) * rh) < 1.0 {
continue;
}
touch_trail.push(CursorStamp {
placement: previous,
size_px: size_px * (1.0 - age * 2.0),
alpha: alpha * 0.18 * (1.0 - age / 0.195),
});
}
}
Some(CursorPlan {
touch_trail,
placement,
prev_placement,
size_px,
taps,
clip,
cursor_type: cursor_type.map(str::to_string),
alpha,
alpha: if touch { alpha * if pressed { 1.0 } else { 0.55 } } else { alpha },
model: model_sprite.map(|s| {
let pointing = pointing_factor([s.hotspot_x, s.hotspot_y]);
cursor_pose(input.track, input.t, lp.cursor_bounce_scale, pointing)
Expand Down Expand Up @@ -9583,6 +9621,41 @@ mod tests {
)
}

#[test]
fn touch_disk_press_and_trail_are_deterministic_and_ignore_3d() {
let mut scene = model_scene();
scene.cursor.theme = "touch".into();
let cfg = crate::config::all().pop().unwrap();
let track = crate::cursor::CursorTrack::touch_fixture(1.25);
let fg = full_frame_geometry();
let plan = |t| {
let mut input = cursor_input(&scene, &cfg, &track, t);
input.live.cursor_model3d = true;
input.live.cursor_motion_blur = 1.0;
plan_cursor(&fg, &input).unwrap()
};
let hover = plan(0.1);
let held = plan(0.7);
assert!(held.model.is_none() && !held.glass);
assert_eq!(held.taps, 1);
assert!((held.size_px / hover.size_px - 0.82).abs() < 1e-5);
assert!(held.alpha > 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading