diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 546b4960a8..dc18767d59 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -123,6 +123,10 @@ pub struct GeneralSettingsStore { pub enable_native_camera_preview: bool, #[serde(default)] pub auto_zoom_on_clicks: bool, + #[serde(default = "default_auto_zoom_amount")] + pub auto_zoom_amount: f64, + #[serde(default = "default_auto_zoom_sensitivity")] + pub auto_zoom_sensitivity: f64, #[serde(default)] pub post_deletion_behaviour: PostDeletionBehaviour, #[serde(default = "default_excluded_windows")] @@ -167,6 +171,14 @@ fn default_max_fps() -> u32 { 60 } +fn default_auto_zoom_amount() -> f64 { + 1.5 +} + +fn default_auto_zoom_sensitivity() -> f64 { + 0.5 +} + fn default_server_url() -> String { std::option_env!("VITE_SERVER_URL") .unwrap_or("https://cap.so") @@ -203,6 +215,8 @@ impl Default for GeneralSettingsStore { recording_countdown: Some(3), enable_native_camera_preview: default_enable_native_camera_preview(), auto_zoom_on_clicks: false, + auto_zoom_amount: default_auto_zoom_amount(), + auto_zoom_sensitivity: default_auto_zoom_sensitivity(), post_deletion_behaviour: PostDeletionBehaviour::DoNothing, excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7497f352dc..5fd7891eff 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2020,12 +2020,26 @@ async fn update_project_config_in_memory( #[specta::specta] #[instrument(skip(editor_instance))] async fn generate_zoom_segments_from_clicks( + app: AppHandle, editor_instance: WindowEditorInstance, ) -> Result, String> { let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; - - let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings); + let project_config = editor_instance.project_config.1.borrow(); + let timeline_segments = project_config + .timeline + .as_ref() + .map(|t| t.segments.as_slice()); + + let settings = GeneralSettingsStore::get(&app)?.unwrap_or_default(); + + let zoom_segments = recording::generate_zoom_segments_for_project( + meta, + recordings, + timeline_segments, + settings.auto_zoom_amount, + settings.auto_zoom_sensitivity, + ); Ok(zoom_segments) } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d65bce27fc..3f5ff45593 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -37,7 +37,7 @@ use std::borrow::Cow; use std::error::Error as StdError; use std::{ any::Any, - collections::{HashMap, VecDeque}, + collections::HashMap, panic::AssertUnwindSafe, path::{Path, PathBuf}, str::FromStr, @@ -1989,30 +1989,30 @@ async fn finalize_studio_recording( Ok(()) } -/// Core logic for generating zoom segments based on mouse click events. -/// This is an experimental feature that automatically creates zoom effects -/// around user interactions to highlight important moments. fn generate_zoom_segments_from_clicks_impl( mut clicks: Vec, mut moves: Vec, max_duration: f64, + base_zoom_amount: f64, + sensitivity: f64, ) -> Vec { const STOP_PADDING_SECONDS: f64 = 0.5; const CLICK_GROUP_TIME_THRESHOLD_SECS: f64 = 2.5; - const CLICK_GROUP_SPATIAL_THRESHOLD: f64 = 0.15; + const BASE_CLICK_GROUP_SPATIAL_THRESHOLD: f64 = 0.15; const CLICK_PRE_PADDING: f64 = 0.4; const CLICK_POST_PADDING: f64 = 1.8; - const MOVEMENT_PRE_PADDING: f64 = 0.3; - const MOVEMENT_POST_PADDING: f64 = 1.5; const MERGE_GAP_THRESHOLD: f64 = 0.8; - const MIN_SEGMENT_DURATION: f64 = 1.0; - const MOVEMENT_WINDOW_SECONDS: f64 = 1.5; - const MOVEMENT_EVENT_DISTANCE_THRESHOLD: f64 = 0.02; - const MOVEMENT_WINDOW_DISTANCE_THRESHOLD: f64 = 0.08; - const AUTO_ZOOM_AMOUNT: f64 = 1.5; - const SHAKE_FILTER_THRESHOLD: f64 = 0.33; - const SHAKE_FILTER_WINDOW_MS: f64 = 150.0; + const BASE_MIN_SEGMENT_DURATION: f64 = 1.0; + let base_zoom_amount = if base_zoom_amount.is_finite() { + base_zoom_amount.clamp(1.0, 3.0) + } else { + 1.5 + }; + let sensitivity = sensitivity.clamp(0.0, 1.0); + let sensitivity_scale = 1.5 - sensitivity; + let click_group_spatial_threshold = BASE_CLICK_GROUP_SPATIAL_THRESHOLD * sensitivity_scale; + let min_segment_duration = BASE_MIN_SEGMENT_DURATION * sensitivity_scale; if max_duration <= 0.0 { return Vec::new(); } @@ -2082,7 +2082,7 @@ fn generate_zoom_segments_from_clicks_impl( (Some((x1, y1)), Some((x2, y2))) => { let dx = x1 - x2; let dy = y1 - y2; - (dx * dx + dy * dy).sqrt() < CLICK_GROUP_SPATIAL_THRESHOLD + (dx * dx + dy * dy).sqrt() < click_group_spatial_threshold } _ => true, }; @@ -2124,96 +2124,6 @@ fn generate_zoom_segments_from_clicks_impl( } } - let mut last_move_by_cursor: HashMap = HashMap::new(); - let mut distance_window: VecDeque<(f64, f64)> = VecDeque::new(); - let mut window_distance = 0.0_f64; - let mut shake_window: VecDeque<(f64, f64, f64)> = VecDeque::new(); - - for mv in moves.iter() { - let time = mv.time_ms / 1000.0; - if time >= activity_end_limit { - break; - } - - let distance = if let Some((_, last_x, last_y)) = last_move_by_cursor.get(&mv.cursor_id) { - let dx = mv.x - last_x; - let dy = mv.y - last_y; - (dx * dx + dy * dy).sqrt() - } else { - 0.0 - }; - - last_move_by_cursor.insert(mv.cursor_id.clone(), (time, mv.x, mv.y)); - - if distance <= f64::EPSILON { - continue; - } - - shake_window.push_back((mv.time_ms, mv.x, mv.y)); - while let Some(&(old_time, _, _)) = shake_window.front() { - if mv.time_ms - old_time > SHAKE_FILTER_WINDOW_MS { - shake_window.pop_front(); - } else { - break; - } - } - - if shake_window.len() >= 3 { - let positions: Vec<(f64, f64)> = - shake_window.iter().map(|(_, x, y)| (*x, *y)).collect(); - let mut direction_changes = 0; - for i in 1..positions.len() - 1 { - let dx1 = positions[i].0 - positions[i - 1].0; - let dy1 = positions[i].1 - positions[i - 1].1; - let dx2 = positions[i + 1].0 - positions[i].0; - let dy2 = positions[i + 1].1 - positions[i].1; - - if (dx1 * dx2 + dy1 * dy2) < 0.0 { - direction_changes += 1; - } - } - - let total_dist: f64 = positions - .windows(2) - .map(|w| ((w[1].0 - w[0].0).powi(2) + (w[1].1 - w[0].1).powi(2)).sqrt()) - .sum(); - - if direction_changes >= 2 && total_dist < SHAKE_FILTER_THRESHOLD * 3.0 { - continue; - } - } - - distance_window.push_back((time, distance)); - window_distance += distance; - - while let Some(&(old_time, old_distance)) = distance_window.front() { - if time - old_time > MOVEMENT_WINDOW_SECONDS { - distance_window.pop_front(); - window_distance -= old_distance; - } else { - break; - } - } - - if window_distance < 0.0 { - window_distance = 0.0; - } - - let significant_movement = distance >= MOVEMENT_EVENT_DISTANCE_THRESHOLD - || window_distance >= MOVEMENT_WINDOW_DISTANCE_THRESHOLD; - - if !significant_movement { - continue; - } - - let start = (time - MOVEMENT_PRE_PADDING).max(0.0); - let end = (time + MOVEMENT_POST_PADDING).min(activity_end_limit); - - if end > start { - intervals.push((start, end)); - } - } - if intervals.is_empty() { return Vec::new(); } @@ -2235,14 +2145,14 @@ fn generate_zoom_segments_from_clicks_impl( .into_iter() .filter_map(|(start, end)| { let duration = end - start; - if duration < MIN_SEGMENT_DURATION { + if duration < min_segment_duration { return None; } Some(ZoomSegment { start, end, - amount: AUTO_ZOOM_AMOUNT, + amount: base_zoom_amount, mode: ZoomMode::Auto, glide_direction: GlideDirection::None, glide_speed: 0.5, @@ -2253,13 +2163,12 @@ fn generate_zoom_segments_from_clicks_impl( .collect() } -/// Generates zoom segments based on mouse click events during recording. -/// Used during the recording completion process. pub fn generate_zoom_segments_from_clicks( recording: &studio_recording::CompletedRecording, recordings: &ProjectRecordingsMeta, + zoom_amount: f64, + zoom_sensitivity: f64, ) -> Vec { - // Build a temporary RecordingMeta so we can use the common implementation let recording_meta = RecordingMeta { platform: None, project_path: recording.project_path.clone(), @@ -2269,14 +2178,15 @@ pub fn generate_zoom_segments_from_clicks( upload: None, }; - generate_zoom_segments_for_project(&recording_meta, recordings) + generate_zoom_segments_for_project(&recording_meta, recordings, None, zoom_amount, zoom_sensitivity) } -/// Generates zoom segments from clicks for an existing project. -/// Used in the editor context where we have RecordingMeta. pub fn generate_zoom_segments_for_project( recording_meta: &RecordingMeta, recordings: &ProjectRecordingsMeta, + timeline_segments: Option<&[TimelineSegment]>, + zoom_amount: f64, + zoom_sensitivity: f64, ) -> Vec { let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else { return Vec::new(); @@ -2309,7 +2219,71 @@ pub fn generate_zoom_segments_for_project( } } - generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration()) + if let Some(segments) = timeline_segments { + if !segments.is_empty() { + let safe_timescale = |ts: f64| -> f64 { + if ts.is_finite() && ts > 0.0 { ts } else { 1.0 } + }; + + let remap_time = |time_ms: f64| -> Option { + let mut timeline_offset_ms = 0.0_f64; + for seg in segments { + let ts = safe_timescale(seg.timescale); + let seg_start_ms = seg.start * 1000.0; + let seg_end_ms = seg.end * 1000.0; + if time_ms >= seg_start_ms && time_ms <= seg_end_ms { + return Some(timeline_offset_ms + (time_ms - seg_start_ms) / ts); + } + timeline_offset_ms += (seg.end - seg.start) / ts * 1000.0; + } + None + }; + + let remapped_clicks: Vec = all_clicks + .into_iter() + .filter_map(|mut c| { + remap_time(c.time_ms).map(|t| { + c.time_ms = t; + c + }) + }) + .collect(); + + let remapped_moves: Vec = all_moves + .into_iter() + .filter_map(|mut m| { + remap_time(m.time_ms).map(|t| { + m.time_ms = t; + m + }) + }) + .collect(); + + let trimmed_duration = segments + .iter() + .map(|s| { + let ts = safe_timescale(s.timescale); + (s.end - s.start) / ts + }) + .sum(); + + return generate_zoom_segments_from_clicks_impl( + remapped_clicks, + remapped_moves, + trimmed_duration, + zoom_amount, + zoom_sensitivity, + ); + } + } + + generate_zoom_segments_from_clicks_impl( + all_clicks, + all_moves, + recordings.duration(), + zoom_amount, + zoom_sensitivity, + ) } fn project_config_from_recording( @@ -2355,7 +2329,12 @@ fn project_config_from_recording( .collect::>(); let zoom_segments = if settings.auto_zoom_on_clicks { - generate_zoom_segments_from_clicks(completed_recording, recordings) + generate_zoom_segments_from_clicks( + completed_recording, + recordings, + settings.auto_zoom_amount, + settings.auto_zoom_sensitivity, + ) } else { Vec::new() }; @@ -2429,8 +2408,13 @@ mod tests { #[test] fn skips_trailing_stop_click() { - let segments = - generate_zoom_segments_from_clicks_impl(vec![click_event(11_900.0)], vec![], 12.0); + let segments = generate_zoom_segments_from_clicks_impl( + vec![click_event(11_900.0)], + vec![], + 12.0, + 1.5, + 0.5, + ); assert!( segments.is_empty(), @@ -2447,7 +2431,7 @@ mod tests { move_event(1_940.0, 0.74, 0.78), ]; - let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0); + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, 1.5, 0.5); assert!( !segments.is_empty(), @@ -2459,6 +2443,72 @@ mod tests { assert!(first.end <= 19.5); } + #[test] + fn variable_zoom_from_settings() { + let clicks = vec![click_event(1_200.0), click_event(4_200.0)]; + let moves = vec![ + move_event(1_500.0, 0.10, 0.12), + move_event(1_720.0, 0.42, 0.45), + move_event(1_940.0, 0.74, 0.78), + ]; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, 2.0, 0.5); + + assert!(!segments.is_empty()); + for seg in &segments { + assert!((seg.amount - 2.0).abs() < 0.01); + } + } + + #[test] + fn sensitivity_affects_segment_count() { + let clicks = vec![ + click_event(1_000.0), + click_event(2_500.0), + click_event(5_000.0), + click_event(7_500.0), + ]; + let moves = vec![ + move_event(1_200.0, 0.1, 0.1), + move_event(2_700.0, 0.5, 0.5), + move_event(5_200.0, 0.8, 0.2), + move_event(7_700.0, 0.2, 0.8), + ]; + + let low_sens = + generate_zoom_segments_from_clicks_impl(clicks.clone(), moves.clone(), 20.0, 1.5, 0.0); + let high_sens = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, 1.5, 1.0); + + assert!( + high_sens.len() >= low_sens.len(), + "high sensitivity ({}) should produce >= segments than low ({})", + high_sens.len(), + low_sens.len() + ); + } + + #[test] + fn backward_compat_default_params() { + let clicks = vec![click_event(1_200.0), click_event(4_200.0)]; + let moves = vec![ + move_event(1_500.0, 0.10, 0.12), + move_event(1_720.0, 0.42, 0.45), + move_event(1_940.0, 0.74, 0.78), + ]; + + let segments = + generate_zoom_segments_from_clicks_impl(clicks.clone(), moves.clone(), 20.0, 1.5, 0.5); + + assert!(!segments.is_empty()); + for seg in &segments { + assert!( + (seg.amount - 1.5).abs() < 0.01, + "default params should produce zoom near 1.5, got {}", + seg.amount + ); + } + } + #[test] fn ignores_cursor_jitter() { let jitter_moves = (0..30) @@ -2469,7 +2519,8 @@ mod tests { }) .collect::>(); - let segments = generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0); + let segments = + generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0, 1.5, 0.5); assert!( segments.is_empty(), diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 4fd0d5d5df..2297d56ad7 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -514,7 +514,7 @@ impl CapWindowId { pub fn min_size(&self) -> Option<(f64, f64)> { Some(match self { Self::Setup => (600.0, 600.0), - Self::Main => (330.0, 395.0), + Self::Main => (330.0, 450.0), Self::Editor { .. } => (1275.0, 800.0), Self::ScreenshotEditor { .. } => (800.0, 600.0), Self::Settings => (700.0, 540.0), diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index bfd7e51126..81752b4880 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -35,7 +35,7 @@ import Mode from "~/components/Mode"; import { RecoveryToast } from "~/components/RecoveryToast"; import Tooltip from "~/components/Tooltip"; import { Input } from "~/routes/editor/ui"; -import { authStore } from "~/store"; +import { authStore, generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -86,6 +86,7 @@ import { } from "../OptionsContext"; import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; +import InfoPill from "./InfoPill"; import MicrophoneSelect from "./MicrophoneSelect"; import ModeInfoPanel from "./ModeInfoPanel"; import SystemAudio from "./SystemAudio"; @@ -95,7 +96,7 @@ import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; import useRequestPermission from "./useRequestPermission"; -const WINDOW_SIZE = { width: 330, height: 395 } as const; +const WINDOW_SIZE = { width: 330, height: 450 } as const; const findCamera = (cameras: CameraWithDetails[], id: DeviceOrModelID) => { return cameras.find((c) => { @@ -1493,6 +1494,8 @@ function Page() { const signIn = createSignInMutation(); + const generalSettings = generalSettingsStore.createQuery(); + const BaseControls = () => (
+ + +
); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 3d1bd22fc7..25dc68987c 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -25,7 +25,6 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { autoCreateShareableLink: false, enableNotifications: true, enableNativeCameraPreview: false, - autoZoomOnClicks: false, custom_cursor_capture2: true, }, ); @@ -82,18 +81,6 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { } /> )} - { - handleChange("autoZoomOnClicks", value); - setTimeout( - () => window.scrollTo({ top: 0, behavior: "instant" }), - 5, - ); - }} - /> diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 8765313716..ce01176e11 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -321,6 +321,8 @@ export function ConfigSidebar() { meta, } = useEditorContext(); + const autoZoomSettings = generalSettingsStore.createQuery(); + const cursorIdleDelay = () => ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? 2) as number; @@ -968,6 +970,53 @@ export function ConfigSidebar() { )} +
+ }> +
+ projectActions.generateAutoZoom()} + leftIcon={} + > + Generate + + projectActions.removeAllZoomSegments()} + leftIcon={} + > + Remove all + +
+
+ }> + + generalSettingsStore.set({ + autoZoomAmount: v[0], + }) + } + minValue={1.0} + maxValue={3.0} + step={0.1} + formatTooltip="x" + /> + + }> + + generalSettingsStore.set({ + autoZoomSensitivity: v[0], + }) + } + minValue={0.0} + maxValue={1.0} + step={0.05} + formatTooltip={(v) => `${Math.round(v * 100)}%`} + /> + +
{ const zoomSelection = selection(); @@ -3087,7 +3136,6 @@ function ZoomSegmentConfig(props: { Auto diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index d48cb92395..af3a08ea8c 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -13,7 +13,6 @@ import { Switch, } from "solid-js"; import { produce } from "solid-js/store"; -import { commands } from "~/utils/tauri"; import { useEditorContext } from "../context"; import { useSegmentContext, @@ -49,21 +48,6 @@ export function ZoomTrack(props: { const [creatingSegmentViaDrag, setCreatingSegmentViaDrag] = createSignal(false); - const handleGenerateZoomSegments = async () => { - try { - const zoomSegments = await commands.generateZoomSegmentsFromClicks(); - setProject("timeline", "zoomSegments", zoomSegments); - if (zoomSegments.length > 0) { - const currentSize = project.cursor?.size ?? 0; - if (currentSize < 200) { - setProject("cursor", "size", 200); - } - } - } catch (error) { - console.error("Failed to generate zoom segments:", error); - } - }; - const newSegmentMinDuration = () => Math.max( MIN_NEW_SEGMENT_PIXEL_WIDTH * secsPerPixel(), @@ -141,8 +125,6 @@ export function ZoomTrack(props: { onMouseEnter={() => setEditorState("timeline", "hoveredTrack", "zoom")} onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} onContextMenu={async (e) => { - if (!import.meta.env.DEV) return; - e.preventDefault(); const menu = await Menu.new({ id: "zoom-track-options", @@ -150,7 +132,12 @@ export function ZoomTrack(props: { { id: "generateZoomSegments", text: "Generate zoom segments from clicks", - action: handleGenerateZoomSegments, + action: () => projectActions.generateAutoZoom(), + }, + { + id: "removeAllZoomSegments", + text: "Remove all zoom segments", + action: () => projectActions.removeAllZoomSegments(), }, ], }); diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index be629d2bf3..1330aabe77 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -287,6 +287,28 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( }), ); }, + generateAutoZoom: async () => { + try { + const zoomSegments = await commands.generateZoomSegmentsFromClicks(); + setProject("timeline", "zoomSegments", zoomSegments); + if (zoomSegments.length > 0) { + const currentSize = project.cursor?.size ?? 0; + if (currentSize < 200) { + setProject("cursor", "size", 200); + } + } + } catch (error) { + console.error("Failed to generate zoom segments:", error); + } + }, + removeAllZoomSegments: () => { + batch(() => { + setProject("timeline", "zoomSegments", []); + if (editorState.timeline.selection?.type === "zoom") { + setEditorState("timeline", "selection", null); + } + }); + }, deleteZoomSegments: (segmentIndices: number[]) => { batch(() => { setProject( diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index f2f49fbea0..d195fa2eb3 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -443,6 +443,14 @@ pub struct ScreenMovementSpring { pub stiffness: f32, pub damping: f32, pub mass: f32, + #[serde(default = "ScreenMovementSpring::default_dead_zone_radius")] + pub dead_zone_radius: f32, +} + +impl ScreenMovementSpring { + fn default_dead_zone_radius() -> f32 { + 0.03 + } } impl Default for ScreenMovementSpring { @@ -451,6 +459,7 @@ impl Default for ScreenMovementSpring { stiffness: 200.0, damping: 40.0, mass: 2.25, + dead_zone_radius: Self::default_dead_zone_radius(), } } } diff --git a/crates/rendering/src/zoom_focus_interpolation.rs b/crates/rendering/src/zoom_focus_interpolation.rs index c698026254..69d00f0af5 100644 --- a/crates/rendering/src/zoom_focus_interpolation.rs +++ b/crates/rendering/src/zoom_focus_interpolation.rs @@ -80,6 +80,8 @@ impl ZoomFocusInterpolator { sim.set_velocity(XY::new(0.0, 0.0)); sim.set_target_position(initial_pos); + let mut last_committed_target = initial_pos; + let mut events = vec![SmoothedFocusEvent { time: 0.0, position: initial_pos, @@ -99,7 +101,17 @@ impl ZoomFocusInterpolator { cursor.position.coord.x as f32, cursor.position.coord.y as f32, ); - sim.set_target_position(target); + let dx = target.x - last_committed_target.x; + let dy = target.y - last_committed_target.y; + let dead_zone_radius = if self.screen_spring.dead_zone_radius.is_finite() && self.screen_spring.dead_zone_radius > 0.0 { + self.screen_spring.dead_zone_radius + } else { + 0.0 + }; + if dx * dx + dy * dy > dead_zone_radius * dead_zone_radius { + last_committed_target = target; + sim.set_target_position(target); + } } sim.run(SAMPLE_INTERVAL_MS as f32); @@ -213,3 +225,117 @@ pub fn apply_edge_snap_to_focus( Coord::new(XY::new(snap_axis(position.0), snap_axis(position.1))) } + +#[cfg(test)] +mod tests { + use super::*; + use cap_project::CursorMoveEvent; + + fn make_cursor_events(moves: Vec<(f64, f64, f64)>) -> CursorEvents { + CursorEvents { + clicks: vec![], + moves: moves + .into_iter() + .map(|(t, x, y)| CursorMoveEvent { + active_modifiers: vec![], + cursor_id: "default".to_string(), + time_ms: t, + x, + y, + }) + .collect(), + } + } + + fn run_interpolator( + cursor_events: &CursorEvents, + dead_zone_radius: f32, + duration_secs: f64, + sample_times: &[f32], + ) -> Vec<(f64, f64)> { + let spring = ScreenMovementSpring { + dead_zone_radius, + ..Default::default() + }; + let mut interp = ZoomFocusInterpolator::new(cursor_events, None, spring, duration_secs); + interp.precompute(); + sample_times + .iter() + .map(|&t| { + let c = interp.interpolate(t); + (c.x, c.y) + }) + .collect() + } + + #[test] + fn dead_zone_suppresses_micro_jitter() { + let moves: Vec<(f64, f64, f64)> = (0..100) + .map(|i| { + let t = i as f64 * 20.0; + let jitter = (i % 3) as f64 * 0.005; + (t, 0.5 + jitter, 0.5 - jitter) + }) + .collect(); + let events = make_cursor_events(moves); + + let sample_times: Vec = (0..50).map(|i| i as f32 * 0.04).collect(); + + let with_dead_zone = run_interpolator(&events, 0.03, 2.0, &sample_times); + let without_dead_zone = run_interpolator(&events, 0.0, 2.0, &sample_times); + + let variance = |pts: &[(f64, f64)]| -> f64 { + let mean_x = pts.iter().map(|p| p.0).sum::() / pts.len() as f64; + let mean_y = pts.iter().map(|p| p.1).sum::() / pts.len() as f64; + pts.iter() + .map(|p| (p.0 - mean_x).powi(2) + (p.1 - mean_y).powi(2)) + .sum::() + / pts.len() as f64 + }; + + assert!( + variance(&with_dead_zone) <= variance(&without_dead_zone), + "dead zone should reduce variance from jitter" + ); + } + + #[test] + fn dead_zone_zero_preserves_behavior() { + let moves = vec![(0.0, 0.3, 0.3), (500.0, 0.5, 0.5), (1000.0, 0.7, 0.7)]; + let events = make_cursor_events(moves); + let sample_times: Vec = (0..20).map(|i| i as f32 * 0.1).collect(); + + let result = run_interpolator(&events, 0.0, 2.0, &sample_times); + + let last = result.last().unwrap(); + assert!( + (last.0 - 0.7).abs() < 0.05, + "with dead_zone=0, spring should track final cursor position, got {}", + last.0 + ); + assert!( + (last.1 - 0.7).abs() < 0.05, + "with dead_zone=0, spring should track final cursor position, got {}", + last.1 + ); + } + + #[test] + fn dead_zone_allows_large_movements() { + let moves = vec![(0.0, 0.2, 0.2), (500.0, 0.5, 0.5), (1000.0, 0.8, 0.8)]; + let events = make_cursor_events(moves); + let sample_times = vec![0.0, 0.5, 1.0, 1.5]; + + let positions = run_interpolator(&events, 0.03, 2.0, &sample_times); + + let start = positions[0]; + let end = positions[positions.len() - 1]; + let dist = ((end.0 - start.0).powi(2) + (end.1 - start.1).powi(2)).sqrt(); + + assert!( + dist > 0.2, + "large cursor movements should be tracked even with dead zone, got dist={}", + dist + ); + } +}