Skip to content

Commit d882822

Browse files
feat: Add configurable cursor idle hide delay and fade (#1184)
* feat: Add configurable cursor idle hide delay and fade * formatting * CodeRabbit suggestion
1 parent 3a172c0 commit d882822

File tree

5 files changed

+242
-27
lines changed

5 files changed

+242
-27
lines changed

apps/desktop/src/routes/editor/ConfigSidebar.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
} from "~/utils/tauri";
5454
import IconLucideMonitor from "~icons/lucide/monitor";
5555
import IconLucideSparkles from "~icons/lucide/sparkles";
56+
import IconLucideTimer from "~icons/lucide/timer";
5657
import { CaptionsTab } from "./CaptionsTab";
5758
import { useEditorContext } from "./context";
5859
import {
@@ -233,6 +234,13 @@ export function ConfigSidebar() {
233234
meta,
234235
} = useEditorContext();
235236

237+
const cursorIdleDelay = () =>
238+
((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ??
239+
2) as number;
240+
241+
const clampIdleDelay = (value: number) =>
242+
Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10;
243+
236244
const [state, setState] = createStore({
237245
selectedTab: "background" as
238246
| "background"
@@ -466,6 +474,39 @@ export function ConfigSidebar() {
466474
step={1}
467475
/>
468476
</Field>
477+
<Field
478+
name="Hide When Idle"
479+
icon={<IconLucideTimer class="size-4" />}
480+
value={
481+
<Toggle
482+
checked={project.cursor.hideWhenIdle}
483+
onChange={(value) =>
484+
setProject("cursor", "hideWhenIdle", value)
485+
}
486+
/>
487+
}
488+
/>
489+
<Show when={project.cursor.hideWhenIdle}>
490+
<Subfield name="Inactivity Delay" class="items-center gap-4">
491+
<div class="flex items-center gap-3 flex-1">
492+
<Slider
493+
class="flex-1"
494+
value={[cursorIdleDelay()]}
495+
onChange={(v) => {
496+
const rounded = clampIdleDelay(v[0]);
497+
setProject("cursor", "hideWhenIdleDelay" as any, rounded);
498+
}}
499+
minValue={0.5}
500+
maxValue={5}
501+
step={0.1}
502+
formatTooltip={(value) => `${value.toFixed(1)}s`}
503+
/>
504+
<span class="w-12 text-xs text-right text-gray-11">
505+
{cursorIdleDelay().toFixed(1)}s
506+
</span>
507+
</div>
508+
</Subfield>
509+
</Show>
469510
<KCollapsible open={!project.cursor.raw}>
470511
<Field
471512
name="Smooth Movement"

apps/desktop/src/utils/tauri.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ export type CurrentRecording = { target: CurrentRecordingTarget; mode: Recording
378378
export type CurrentRecordingChanged = null
379379
export type CurrentRecordingTarget = { window: { id: WindowId; bounds: LogicalBounds } } | { screen: { id: DisplayId } } | { area: { screen: DisplayId; bounds: LogicalBounds } }
380380
export type CursorAnimationStyle = "regular" | "slow" | "fast"
381-
export type CursorConfiguration = { hide?: boolean; hideWhenIdle: boolean; size: number; type: CursorType; animationStyle: CursorAnimationStyle; tension: number; mass: number; friction: number; raw?: boolean; motionBlur?: number; useSvg?: boolean }
381+
export type CursorConfiguration = { hide?: boolean; hideWhenIdle?: boolean; hideWhenIdleDelay?: number; size: number; type: CursorType; animationStyle: CursorAnimationStyle; tension: number; mass: number; friction: number; raw?: boolean; motionBlur?: number; useSvg?: boolean }
382382
export type CursorMeta = { imagePath: string; hotspot: XY<number>; shape?: string | null }
383383
export type CursorType = "pointer" | "circle"
384384
export type Cursors = { [key in string]: string } | { [key in string]: CursorMeta }

crates/project/src/configuration.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,10 @@ pub enum CursorAnimationStyle {
391391
pub struct CursorConfiguration {
392392
#[serde(default)]
393393
pub hide: bool,
394-
hide_when_idle: bool,
394+
#[serde(default)]
395+
pub hide_when_idle: bool,
396+
#[serde(default = "CursorConfiguration::default_hide_when_idle_delay")]
397+
pub hide_when_idle_delay: f32,
395398
pub size: u32,
396399
r#type: CursorType,
397400
pub animation_style: CursorAnimationStyle,
@@ -415,6 +418,7 @@ impl Default for CursorConfiguration {
415418
Self {
416419
hide: false,
417420
hide_when_idle: false,
421+
hide_when_idle_delay: Self::default_hide_when_idle_delay(),
418422
size: 100,
419423
r#type: CursorType::default(),
420424
animation_style: CursorAnimationStyle::Regular,
@@ -431,6 +435,10 @@ impl CursorConfiguration {
431435
fn default_raw() -> bool {
432436
true
433437
}
438+
439+
fn default_hide_when_idle_delay() -> f32 {
440+
2.0
441+
}
434442
}
435443

436444
#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)]

crates/rendering/src/layers/cursor.rs

Lines changed: 175 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use crate::{
1414
const CURSOR_CLICK_DURATION: f64 = 0.25;
1515
const CURSOR_CLICK_DURATION_MS: f64 = CURSOR_CLICK_DURATION * 1000.0;
1616
const CLICK_SHRINK_SIZE: f32 = 0.7;
17+
const CURSOR_IDLE_MIN_DELAY_MS: f64 = 500.0;
18+
const CURSOR_IDLE_FADE_OUT_MS: f64 = 400.0;
1719

1820
/// The size to render the svg to.
1921
static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200;
@@ -212,6 +214,24 @@ impl CursorLayer {
212214
let speed = (velocity[0] * velocity[0] + velocity[1] * velocity[1]).sqrt();
213215
let motion_blur_amount = (speed * 0.3).min(1.0) * 0.0; // uniforms.project.cursor.motion_blur;
214216

217+
let mut cursor_opacity = 1.0f32;
218+
if uniforms.project.cursor.hide_when_idle && !cursor.moves.is_empty() {
219+
let hide_delay_secs = uniforms
220+
.project
221+
.cursor
222+
.hide_when_idle_delay
223+
.max((CURSOR_IDLE_MIN_DELAY_MS / 1000.0) as f32);
224+
let hide_delay_ms = (hide_delay_secs as f64 * 1000.0).max(CURSOR_IDLE_MIN_DELAY_MS);
225+
cursor_opacity = compute_cursor_idle_opacity(
226+
cursor,
227+
segment_frames.recording_time as f64 * 1000.0,
228+
hide_delay_ms,
229+
);
230+
if cursor_opacity <= f32::EPSILON {
231+
cursor_opacity = 0.0;
232+
}
233+
}
234+
215235
// Remove all cursor assets if the svg configuration changes.
216236
// it might change the texture.
217237
//
@@ -336,20 +356,27 @@ impl CursorLayer {
336356
zoom,
337357
) - zoomed_position;
338358

339-
let uniforms = CursorUniforms {
340-
position: [zoomed_position.x as f32, zoomed_position.y as f32],
341-
size: [zoomed_size.x as f32, zoomed_size.y as f32],
342-
output_size: [uniforms.output_size.0 as f32, uniforms.output_size.1 as f32],
359+
let cursor_uniforms = CursorUniforms {
360+
position_size: [
361+
zoomed_position.x as f32,
362+
zoomed_position.y as f32,
363+
zoomed_size.x as f32,
364+
zoomed_size.y as f32,
365+
],
366+
output_size: [
367+
uniforms.output_size.0 as f32,
368+
uniforms.output_size.1 as f32,
369+
0.0,
370+
0.0,
371+
],
343372
screen_bounds: uniforms.display.target_bounds,
344-
velocity,
345-
motion_blur_amount,
346-
_alignment: [0.0; 3],
373+
velocity_blur_opacity: [velocity[0], velocity[1], motion_blur_amount, cursor_opacity],
347374
};
348375

349376
constants.queue.write_buffer(
350377
&self.statics.uniform_buffer,
351378
0,
352-
bytemuck::cast_slice(&[uniforms]),
379+
bytemuck::cast_slice(&[cursor_uniforms]),
353380
);
354381

355382
self.bind_group = Some(
@@ -367,16 +394,149 @@ impl CursorLayer {
367394
}
368395
}
369396

370-
#[repr(C, align(16))]
397+
#[repr(C)]
371398
#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)]
372399
pub struct CursorUniforms {
373-
position: [f32; 2],
374-
size: [f32; 2],
375-
output_size: [f32; 2],
400+
position_size: [f32; 4],
401+
output_size: [f32; 4],
376402
screen_bounds: [f32; 4],
377-
velocity: [f32; 2],
378-
motion_blur_amount: f32,
379-
_alignment: [f32; 3],
403+
velocity_blur_opacity: [f32; 4],
404+
}
405+
406+
fn compute_cursor_idle_opacity(
407+
cursor: &CursorEvents,
408+
current_time_ms: f64,
409+
hide_delay_ms: f64,
410+
) -> f32 {
411+
if cursor.moves.is_empty() {
412+
return 0.0;
413+
}
414+
415+
if current_time_ms <= cursor.moves[0].time_ms {
416+
return 1.0;
417+
}
418+
419+
let Some(last_index) = cursor
420+
.moves
421+
.iter()
422+
.rposition(|event| event.time_ms <= current_time_ms)
423+
else {
424+
return 1.0;
425+
};
426+
427+
let last_move = &cursor.moves[last_index];
428+
429+
let time_since_move = (current_time_ms - last_move.time_ms).max(0.0);
430+
431+
let mut opacity = compute_cursor_fade_in(cursor, current_time_ms, hide_delay_ms);
432+
433+
let fade_out = if time_since_move <= hide_delay_ms {
434+
1.0
435+
} else {
436+
let delta = time_since_move - hide_delay_ms;
437+
let fade = 1.0 - smoothstep64(0.0, CURSOR_IDLE_FADE_OUT_MS, delta);
438+
fade.clamp(0.0, 1.0) as f32
439+
};
440+
441+
opacity *= fade_out;
442+
opacity.clamp(0.0, 1.0)
443+
}
444+
445+
fn smoothstep64(edge0: f64, edge1: f64, x: f64) -> f64 {
446+
if edge1 <= edge0 {
447+
return if x < edge0 { 0.0 } else { 1.0 };
448+
}
449+
450+
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
451+
t * t * (3.0 - 2.0 * t)
452+
}
453+
454+
fn compute_cursor_fade_in(cursor: &CursorEvents, current_time_ms: f64, hide_delay_ms: f64) -> f32 {
455+
let resume_time = cursor
456+
.moves
457+
.windows(2)
458+
.rev()
459+
.find(|pair| {
460+
let prev = &pair[0];
461+
let next = &pair[1];
462+
next.time_ms <= current_time_ms && next.time_ms - prev.time_ms > hide_delay_ms
463+
})
464+
.map(|pair| pair[1].time_ms);
465+
466+
let Some(resume_time_ms) = resume_time else {
467+
return 1.0;
468+
};
469+
470+
let time_since_resume = (current_time_ms - resume_time_ms).max(0.0);
471+
472+
smoothstep64(0.0, CURSOR_IDLE_FADE_OUT_MS, time_since_resume) as f32
473+
}
474+
475+
#[cfg(test)]
476+
mod tests {
477+
use super::*;
478+
479+
fn move_event(time_ms: f64, x: f64, y: f64) -> CursorMoveEvent {
480+
CursorMoveEvent {
481+
active_modifiers: vec![],
482+
cursor_id: "pointer".into(),
483+
time_ms,
484+
x,
485+
y,
486+
}
487+
}
488+
489+
fn cursor_events(times: &[(f64, f64, f64)]) -> CursorEvents {
490+
CursorEvents {
491+
moves: times
492+
.iter()
493+
.map(|(time, x, y)| move_event(*time, *x, *y))
494+
.collect(),
495+
clicks: vec![],
496+
}
497+
}
498+
499+
#[test]
500+
fn opacity_stays_visible_with_recent_move() {
501+
let cursor = cursor_events(&[(0.0, 0.0, 0.0), (1500.0, 0.1, 0.1)]);
502+
503+
let opacity = compute_cursor_idle_opacity(&cursor, 2000.0, 2000.0);
504+
505+
assert_eq!(opacity, 1.0);
506+
}
507+
508+
#[test]
509+
fn opacity_fades_once_past_delay() {
510+
let cursor = cursor_events(&[(0.0, 0.0, 0.0)]);
511+
512+
let opacity = compute_cursor_idle_opacity(&cursor, 3000.0, 1000.0);
513+
514+
assert_eq!(opacity, 0.0);
515+
}
516+
517+
#[test]
518+
fn opacity_fades_in_after_long_inactivity() {
519+
let cursor = cursor_events(&[(0.0, 0.0, 0.0), (5000.0, 0.5, 0.5)]);
520+
521+
let hide_delay_ms = 2000.0;
522+
523+
let at_resume = compute_cursor_idle_opacity(&cursor, 5000.0, hide_delay_ms);
524+
assert_eq!(at_resume, 0.0);
525+
526+
let halfway = compute_cursor_idle_opacity(
527+
&cursor,
528+
5000.0 + CURSOR_IDLE_FADE_OUT_MS / 2.0,
529+
hide_delay_ms,
530+
);
531+
assert!((halfway - 0.5).abs() < 0.05);
532+
533+
let after_fade = compute_cursor_idle_opacity(
534+
&cursor,
535+
5000.0 + CURSOR_IDLE_FADE_OUT_MS * 2.0,
536+
hide_delay_ms,
537+
);
538+
assert_eq!(after_fade, 1.0);
539+
}
380540
}
381541

382542
fn get_click_t(clicks: &[CursorClickEvent], time_ms: f64) -> f32 {

crates/rendering/src/shaders/cursor.wgsl

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ struct VertexOutput {
44
};
55

66
struct Uniforms {
7-
position: vec2<f32>,
8-
size: vec2<f32>,
7+
position_size: vec4<f32>,
98
output_size: vec4<f32>,
109
screen_bounds: vec4<f32>,
11-
velocity: vec2<f32>,
12-
motion_blur_amount: f32,
10+
velocity_blur_opacity: vec4<f32>,
1311
};
1412

1513
@group(0) @binding(0)
@@ -38,14 +36,15 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
3836
);
3937

4038
let pos = positions[vertex_index];
41-
let screen_pos = uniforms.position.xy;
39+
let screen_pos = uniforms.position_size.xy;
40+
let cursor_size = uniforms.position_size.zw;
4241

4342
// Calculate final position - centered around cursor position
4443
// Flip the Y coordinate by subtracting from output height
4544
var adjusted_pos = screen_pos;
4645
adjusted_pos.y = uniforms.output_size.y - adjusted_pos.y; // Flip Y coordinate
4746

48-
let final_pos = ((pos * uniforms.size) + adjusted_pos) / uniforms.output_size.xy * 2.0 - 1.0;
47+
let final_pos = ((pos * cursor_size) + adjusted_pos) / uniforms.output_size.xy * 2.0 - 1.0;
4948

5049
var output: VertexOutput;
5150
output.position = vec4<f32>(final_pos, 0.0, 1.0);
@@ -61,11 +60,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
6160
var weight_sum = 0.0;
6261

6362
// Calculate velocity magnitude for adaptive blur strength
64-
let velocity_mag = length(uniforms.velocity);
65-
let adaptive_blur = uniforms.motion_blur_amount * smoothstep(0.0, 50.0, velocity_mag);
63+
let velocity = uniforms.velocity_blur_opacity.xy;
64+
let motion_blur_amount = uniforms.velocity_blur_opacity.z;
65+
let opacity = uniforms.velocity_blur_opacity.w;
66+
67+
let velocity_mag = length(velocity);
68+
let adaptive_blur = motion_blur_amount * smoothstep(0.0, 50.0, velocity_mag);
6669

6770
// Calculate blur direction from velocity
68-
var blur_dir = uniforms.velocity;
71+
var blur_dir = velocity;
6972

7073
// Enhanced blur trail
7174
let max_blur_offset = 3.0 * adaptive_blur;
@@ -99,5 +102,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
99102
);
100103
}
101104

102-
return final_color * vec4<f32>(1.0, 1.0, 1.0, 1.0 - uniforms.motion_blur_amount * 0.2);
105+
final_color *= vec4<f32>(1.0, 1.0, 1.0, 1.0 - motion_blur_amount * 0.2);
106+
final_color *= opacity;
107+
108+
return final_color;
103109
}

0 commit comments

Comments
 (0)