From 8ef83625b4a156c442cb0ac8efa6660967cb07c4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:29:23 +0100 Subject: [PATCH 1/3] feat: Add configurable cursor idle hide delay and fade --- .../src/routes/editor/ConfigSidebar.tsx | 42 ++++ apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/configuration.rs | 10 +- crates/rendering/src/layers/cursor.rs | 190 ++++++++++++++++-- crates/rendering/src/shaders/cursor.wgsl | 26 ++- 5 files changed, 243 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 3663d04e78..af4e4638ee 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -53,6 +53,7 @@ import { } from "~/utils/tauri"; import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; +import IconLucideTimer from "~icons/lucide/timer"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; import { @@ -227,6 +228,12 @@ export function ConfigSidebar() { meta, } = useEditorContext(); + const cursorIdleDelay = () => + ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? 2) as number; + + const clampIdleDelay = (value: number) => + Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10; + const [state, setState] = createStore({ selectedTab: "background" as | "background" @@ -460,6 +467,41 @@ export function ConfigSidebar() { step={1} /> + } + value={ + setProject("cursor", "hideWhenIdle", value)} + /> + } + /> + + +
+ { + const rounded = clampIdleDelay(v[0]); + setProject( + "cursor", + "hideWhenIdleDelay" as any, + rounded, + ); + }} + minValue={0.5} + maxValue={5} + step={0.1} + formatTooltip={(value) => `${value.toFixed(1)}s`} + /> + + {cursorIdleDelay().toFixed(1)}s + +
+
+
; shape?: string | null } export type CursorType = "pointer" | "circle" export type Cursors = { [key in string]: string } | { [key in string]: CursorMeta } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 289824234a..1600653d21 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -391,7 +391,10 @@ pub enum CursorAnimationStyle { pub struct CursorConfiguration { #[serde(default)] pub hide: bool, - hide_when_idle: bool, + #[serde(default)] + pub hide_when_idle: bool, + #[serde(default = "CursorConfiguration::default_hide_when_idle_delay")] + pub hide_when_idle_delay: f32, pub size: u32, r#type: CursorType, pub animation_style: CursorAnimationStyle, @@ -415,6 +418,7 @@ impl Default for CursorConfiguration { Self { hide: false, hide_when_idle: false, + hide_when_idle_delay: Self::default_hide_when_idle_delay(), size: 100, r#type: CursorType::default(), animation_style: CursorAnimationStyle::Regular, @@ -431,6 +435,10 @@ impl CursorConfiguration { fn default_raw() -> bool { true } + + fn default_hide_when_idle_delay() -> f32 { + 2.0 + } } #[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index 00a5808472..b5bbf9a7b9 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -14,6 +14,8 @@ use crate::{ const CURSOR_CLICK_DURATION: f64 = 0.25; const CURSOR_CLICK_DURATION_MS: f64 = CURSOR_CLICK_DURATION * 1000.0; const CLICK_SHRINK_SIZE: f32 = 0.7; +const CURSOR_IDLE_MIN_DELAY_MS: f64 = 500.0; +const CURSOR_IDLE_FADE_OUT_MS: f64 = 400.0; /// The size to render the svg to. static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200; @@ -212,6 +214,24 @@ impl CursorLayer { let speed = (velocity[0] * velocity[0] + velocity[1] * velocity[1]).sqrt(); let motion_blur_amount = (speed * 0.3).min(1.0) * 0.0; // uniforms.project.cursor.motion_blur; + let mut cursor_opacity = 1.0f32; + if uniforms.project.cursor.hide_when_idle { + let hide_delay_secs = uniforms + .project + .cursor + .hide_when_idle_delay + .max((CURSOR_IDLE_MIN_DELAY_MS / 1000.0) as f32); + let hide_delay_ms = (hide_delay_secs as f64 * 1000.0).max(CURSOR_IDLE_MIN_DELAY_MS); + cursor_opacity = compute_cursor_idle_opacity( + cursor, + segment_frames.recording_time as f64 * 1000.0, + hide_delay_ms, + ); + if cursor_opacity <= f32::EPSILON { + cursor_opacity = 0.0; + } + } + // Remove all cursor assets if the svg configuration changes. // it might change the texture. // @@ -336,20 +356,27 @@ impl CursorLayer { zoom, ) - zoomed_position; - let uniforms = CursorUniforms { - position: [zoomed_position.x as f32, zoomed_position.y as f32], - size: [zoomed_size.x as f32, zoomed_size.y as f32], - output_size: [uniforms.output_size.0 as f32, uniforms.output_size.1 as f32], + let cursor_uniforms = CursorUniforms { + position_size: [ + zoomed_position.x as f32, + zoomed_position.y as f32, + zoomed_size.x as f32, + zoomed_size.y as f32, + ], + output_size: [ + uniforms.output_size.0 as f32, + uniforms.output_size.1 as f32, + 0.0, + 0.0, + ], screen_bounds: uniforms.display.target_bounds, - velocity, - motion_blur_amount, - _alignment: [0.0; 3], + velocity_blur_opacity: [velocity[0], velocity[1], motion_blur_amount, cursor_opacity], }; constants.queue.write_buffer( &self.statics.uniform_buffer, 0, - bytemuck::cast_slice(&[uniforms]), + bytemuck::cast_slice(&[cursor_uniforms]), ); self.bind_group = Some( @@ -367,16 +394,149 @@ impl CursorLayer { } } -#[repr(C, align(16))] +#[repr(C)] #[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] pub struct CursorUniforms { - position: [f32; 2], - size: [f32; 2], - output_size: [f32; 2], + position_size: [f32; 4], + output_size: [f32; 4], screen_bounds: [f32; 4], - velocity: [f32; 2], - motion_blur_amount: f32, - _alignment: [f32; 3], + velocity_blur_opacity: [f32; 4], +} + +fn compute_cursor_idle_opacity( + cursor: &CursorEvents, + current_time_ms: f64, + hide_delay_ms: f64, +) -> f32 { + if cursor.moves.is_empty() { + return 0.0; + } + + if current_time_ms <= cursor.moves[0].time_ms { + return 1.0; + } + + let Some(last_index) = cursor + .moves + .iter() + .rposition(|event| event.time_ms <= current_time_ms) + else { + return 1.0; + }; + + let last_move = &cursor.moves[last_index]; + + let time_since_move = (current_time_ms - last_move.time_ms).max(0.0); + + let mut opacity = compute_cursor_fade_in(cursor, current_time_ms, hide_delay_ms); + + let fade_out = if time_since_move <= hide_delay_ms { + 1.0 + } else { + let delta = time_since_move - hide_delay_ms; + let fade = 1.0 - smoothstep64(0.0, CURSOR_IDLE_FADE_OUT_MS, delta); + fade.clamp(0.0, 1.0) as f32 + }; + + opacity *= fade_out; + opacity.clamp(0.0, 1.0) +} + +fn smoothstep64(edge0: f64, edge1: f64, x: f64) -> f64 { + if edge1 <= edge0 { + return if x < edge0 { 0.0 } else { 1.0 }; + } + + let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn compute_cursor_fade_in(cursor: &CursorEvents, current_time_ms: f64, hide_delay_ms: f64) -> f32 { + let resume_time = cursor + .moves + .windows(2) + .rev() + .find(|pair| { + let prev = &pair[0]; + let next = &pair[1]; + next.time_ms <= current_time_ms && next.time_ms - prev.time_ms > hide_delay_ms + }) + .map(|pair| pair[1].time_ms); + + let Some(resume_time_ms) = resume_time else { + return 1.0; + }; + + let time_since_resume = (current_time_ms - resume_time_ms).max(0.0); + + smoothstep64(0.0, CURSOR_IDLE_FADE_OUT_MS, time_since_resume) as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn move_event(time_ms: f64, x: f64, y: f64) -> CursorMoveEvent { + CursorMoveEvent { + active_modifiers: vec![], + cursor_id: "pointer".into(), + time_ms, + x, + y, + } + } + + fn cursor_events(times: &[(f64, f64, f64)]) -> CursorEvents { + CursorEvents { + moves: times + .iter() + .map(|(time, x, y)| move_event(*time, *x, *y)) + .collect(), + clicks: vec![], + } + } + + #[test] + fn opacity_stays_visible_with_recent_move() { + let cursor = cursor_events(&[(0.0, 0.0, 0.0), (1500.0, 0.1, 0.1)]); + + let opacity = compute_cursor_idle_opacity(&cursor, 2000.0, 2000.0); + + assert_eq!(opacity, 1.0); + } + + #[test] + fn opacity_fades_once_past_delay() { + let cursor = cursor_events(&[(0.0, 0.0, 0.0)]); + + let opacity = compute_cursor_idle_opacity(&cursor, 3000.0, 1000.0); + + assert_eq!(opacity, 0.0); + } + + #[test] + fn opacity_fades_in_after_long_inactivity() { + let cursor = cursor_events(&[(0.0, 0.0, 0.0), (5000.0, 0.5, 0.5)]); + + let hide_delay_ms = 2000.0; + + let at_resume = compute_cursor_idle_opacity(&cursor, 5000.0, hide_delay_ms); + assert_eq!(at_resume, 0.0); + + let halfway = compute_cursor_idle_opacity( + &cursor, + 5000.0 + CURSOR_IDLE_FADE_OUT_MS / 2.0, + hide_delay_ms, + ); + assert!((halfway - 0.5).abs() < 0.05); + + let after_fade = compute_cursor_idle_opacity( + &cursor, + 5000.0 + CURSOR_IDLE_FADE_OUT_MS * 2.0, + hide_delay_ms, + ); + assert_eq!(after_fade, 1.0); + } } fn get_click_t(clicks: &[CursorClickEvent], time_ms: f64) -> f32 { diff --git a/crates/rendering/src/shaders/cursor.wgsl b/crates/rendering/src/shaders/cursor.wgsl index dbffc5b87d..4c3e3e2e04 100644 --- a/crates/rendering/src/shaders/cursor.wgsl +++ b/crates/rendering/src/shaders/cursor.wgsl @@ -4,12 +4,10 @@ struct VertexOutput { }; struct Uniforms { - position: vec2, - size: vec2, + position_size: vec4, output_size: vec4, screen_bounds: vec4, - velocity: vec2, - motion_blur_amount: f32, + velocity_blur_opacity: vec4, }; @group(0) @binding(0) @@ -38,14 +36,15 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { ); let pos = positions[vertex_index]; - let screen_pos = uniforms.position.xy; + let screen_pos = uniforms.position_size.xy; + let cursor_size = uniforms.position_size.zw; // Calculate final position - centered around cursor position // Flip the Y coordinate by subtracting from output height var adjusted_pos = screen_pos; adjusted_pos.y = uniforms.output_size.y - adjusted_pos.y; // Flip Y coordinate - let final_pos = ((pos * uniforms.size) + adjusted_pos) / uniforms.output_size.xy * 2.0 - 1.0; + let final_pos = ((pos * cursor_size) + adjusted_pos) / uniforms.output_size.xy * 2.0 - 1.0; var output: VertexOutput; output.position = vec4(final_pos, 0.0, 1.0); @@ -61,11 +60,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { var weight_sum = 0.0; // Calculate velocity magnitude for adaptive blur strength - let velocity_mag = length(uniforms.velocity); - let adaptive_blur = uniforms.motion_blur_amount * smoothstep(0.0, 50.0, velocity_mag); + let velocity = uniforms.velocity_blur_opacity.xy; + let motion_blur_amount = uniforms.velocity_blur_opacity.z; + let opacity = uniforms.velocity_blur_opacity.w; + + let velocity_mag = length(velocity); + let adaptive_blur = motion_blur_amount * smoothstep(0.0, 50.0, velocity_mag); // Calculate blur direction from velocity - var blur_dir = uniforms.velocity; + var blur_dir = velocity; // Enhanced blur trail let max_blur_offset = 3.0 * adaptive_blur; @@ -99,5 +102,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { ); } - return final_color * vec4(1.0, 1.0, 1.0, 1.0 - uniforms.motion_blur_amount * 0.2); + final_color *= vec4(1.0, 1.0, 1.0, 1.0 - motion_blur_amount * 0.2); + final_color *= opacity; + + return final_color; } From 972b6e346143ad12e98dc5f327f1eeaab78e33d1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Oct 2025 23:36:18 +0100 Subject: [PATCH 2/3] formatting --- apps/desktop/src/routes/editor/ConfigSidebar.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index a1014426e8..a4e4facfe8 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -235,7 +235,8 @@ export function ConfigSidebar() { } = useEditorContext(); const cursorIdleDelay = () => - ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? 2) as number; + ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? + 2) as number; const clampIdleDelay = (value: number) => Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10; @@ -479,7 +480,9 @@ export function ConfigSidebar() { value={ setProject("cursor", "hideWhenIdle", value)} + onChange={(value) => + setProject("cursor", "hideWhenIdle", value) + } /> } /> @@ -491,11 +494,7 @@ export function ConfigSidebar() { value={[cursorIdleDelay()]} onChange={(v) => { const rounded = clampIdleDelay(v[0]); - setProject( - "cursor", - "hideWhenIdleDelay" as any, - rounded, - ); + setProject("cursor", "hideWhenIdleDelay" as any, rounded); }} minValue={0.5} maxValue={5} From becfaddeae59859fa152e1b458e45127dcab92a6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Oct 2025 23:38:47 +0100 Subject: [PATCH 3/3] CodeRabbit suggestion --- crates/rendering/src/layers/cursor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index b5bbf9a7b9..a9acd6cba9 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -215,7 +215,7 @@ impl CursorLayer { let motion_blur_amount = (speed * 0.3).min(1.0) * 0.0; // uniforms.project.cursor.motion_blur; let mut cursor_opacity = 1.0f32; - if uniforms.project.cursor.hide_when_idle { + if uniforms.project.cursor.hide_when_idle && !cursor.moves.is_empty() { let hide_delay_secs = uniforms .project .cursor