Skip to content
Merged
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
41 changes: 41 additions & 0 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -233,6 +234,13 @@ 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"
Expand Down Expand Up @@ -466,6 +474,39 @@ export function ConfigSidebar() {
step={1}
/>
</Field>
<Field
name="Hide When Idle"
icon={<IconLucideTimer class="size-4" />}
value={
<Toggle
checked={project.cursor.hideWhenIdle}
onChange={(value) =>
setProject("cursor", "hideWhenIdle", value)
}
/>
}
/>
<Show when={project.cursor.hideWhenIdle}>
<Subfield name="Inactivity Delay" class="items-center gap-4">
<div class="flex items-center gap-3 flex-1">
<Slider
class="flex-1"
value={[cursorIdleDelay()]}
onChange={(v) => {
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`}
/>
<span class="w-12 text-xs text-right text-gray-11">
{cursorIdleDelay().toFixed(1)}s
</span>
</div>
</Subfield>
</Show>
<KCollapsible open={!project.cursor.raw}>
<Field
name="Smooth Movement"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/utils/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export type CurrentRecording = { target: CurrentRecordingTarget; mode: Recording
export type CurrentRecordingChanged = null
export type CurrentRecordingTarget = { window: { id: WindowId; bounds: LogicalBounds } } | { screen: { id: DisplayId } } | { area: { screen: DisplayId; bounds: LogicalBounds } }
export type CursorAnimationStyle = "regular" | "slow" | "fast"
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 }
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 }
export type CursorMeta = { imagePath: string; hotspot: XY<number>; shape?: string | null }
export type CursorType = "pointer" | "circle"
export type Cursors = { [key in string]: string } | { [key in string]: CursorMeta }
Expand Down
10 changes: 9 additions & 1 deletion crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)]
Expand Down
190 changes: 175 additions & 15 deletions crates/rendering/src/layers/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 && !cursor.moves.is_empty() {
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.
//
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
26 changes: 16 additions & 10 deletions crates/rendering/src/shaders/cursor.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ struct VertexOutput {
};

struct Uniforms {
position: vec2<f32>,
size: vec2<f32>,
position_size: vec4<f32>,
output_size: vec4<f32>,
screen_bounds: vec4<f32>,
velocity: vec2<f32>,
motion_blur_amount: f32,
velocity_blur_opacity: vec4<f32>,
};

@group(0) @binding(0)
Expand Down Expand Up @@ -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<f32>(final_pos, 0.0, 1.0);
Expand All @@ -61,11 +60,15 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
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;
Expand Down Expand Up @@ -99,5 +102,8 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
);
}

return final_color * vec4<f32>(1.0, 1.0, 1.0, 1.0 - uniforms.motion_blur_amount * 0.2);
final_color *= vec4<f32>(1.0, 1.0, 1.0, 1.0 - motion_blur_amount * 0.2);
final_color *= opacity;

return final_color;
}
Loading