Skip to content
Closed
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
140 changes: 123 additions & 17 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
type ZoomSegment,
} from "~/utils/tauri";
import IconLucideMonitor from "~icons/lucide/monitor";
import IconLucideMousePointerClick from "~icons/lucide/mouse-pointer-click";
import IconLucideSparkles from "~icons/lucide/sparkles";
import IconLucideTimer from "~icons/lucide/timer";
import { CaptionsTab } from "./CaptionsTab";
Expand Down Expand Up @@ -224,6 +225,12 @@ const TAB_IDS = {
hotkeys: "hotkeys",
} as const;

const DEFAULT_CURSOR_SHADOW = {
size: 1,
blur: 0.8,
opacity: 0.25,
} as const;

export function ConfigSidebar() {
const {
project,
Expand All @@ -242,6 +249,33 @@ export function ConfigSidebar() {
const clampIdleDelay = (value: number) =>
Math.round(Math.min(5, Math.max(0.5, value)) * 10) / 10;

const clampShadowSize = (value: number) =>
Math.round(Math.min(3, Math.max(0, value)) * 100) / 100;

const clampShadowBlur = (value: number) =>
Math.round(Math.min(1, Math.max(0, value)) * 100) / 100;

const clampShadowOpacity = (value: number) =>
Math.min(1, Math.max(0, value));

const cursorShadowSize = () =>
clampShadowSize(
((project.cursor as { shadowSize?: number }).shadowSize ??
DEFAULT_CURSOR_SHADOW.size) as number,
);

const cursorShadowBlur = () =>
clampShadowBlur(
((project.cursor as { shadowBlur?: number }).shadowBlur ??
DEFAULT_CURSOR_SHADOW.blur) as number,
);

const cursorShadowOpacity = () =>
clampShadowOpacity(
((project.cursor as { shadowStrength?: number }).shadowStrength ??
DEFAULT_CURSOR_SHADOW.opacity) as number,
);

const [state, setState] = createStore({
selectedTab: "background" as
| "background"
Expand Down Expand Up @@ -474,16 +508,82 @@ export function ConfigSidebar() {
/>
}
/>
<Show when={!project.cursor.hide}>
<Field name="Size" icon={<IconCapEnlarge />}>
<Show when={!project.cursor.hide}>
<Field name="Size" icon={<IconCapEnlarge />}>
<Slider
value={[project.cursor.size]}
onChange={(v) => setProject("cursor", "size", v[0])}
minValue={20}
maxValue={300}
step={1}
/>
</Field>
<Field name="Shadow Size" icon={<IconLucideMousePointerClick class="size-4" />}>
<div class="flex items-center gap-3">
<Slider
value={[project.cursor.size]}
onChange={(v) => setProject("cursor", "size", v[0])}
minValue={20}
maxValue={300}
step={1}
class="flex-1"
value={[cursorShadowSize()]}
onChange={(v) =>
setProject(
"cursor",
"shadowSize" as any,
clampShadowSize(v[0]),
)
}
minValue={0}
maxValue={3}
step={0.01}
formatTooltip={(value) => `${value.toFixed(2)}×`}
/>
</Field>
<span class="w-16 text-xs text-right text-gray-11">
{cursorShadowSize().toFixed(2)}×
</span>
</div>
</Field>
<Field name="Shadow Blur" icon={<IconLucideMousePointerClick class="size-4" />}>
<div class="flex items-center gap-3">
<Slider
class="flex-1"
value={[cursorShadowBlur()]}
onChange={(v) =>
setProject(
"cursor",
"shadowBlur" as any,
clampShadowBlur(v[0]),
)
}
minValue={0}
maxValue={1}
step={0.01}
formatTooltip={(value) => `${Math.round(value * 100)}%`}
/>
<span class="w-12 text-xs text-right text-gray-11">
{Math.round(cursorShadowBlur() * 100)}%
</span>
</div>
</Field>
<Field name="Shadow Opacity" icon={<IconLucideMousePointerClick class="size-4" />}>
<div class="flex items-center gap-3">
<Slider
class="flex-1"
value={[cursorShadowOpacity()]}
onChange={(v) =>
setProject(
"cursor",
"shadowStrength" as any,
clampShadowOpacity(v[0]),
)
}
minValue={0}
maxValue={1}
step={0.01}
formatTooltip={(value) => `${Math.round(value * 100)}%`}
/>
<span class="w-12 text-xs text-right text-gray-11">
{Math.round(cursorShadowOpacity() * 100)}%
</span>
</div>
</Field>
<Field
name="Hide When Idle"
icon={<IconLucideTimer class="size-4" />}
Expand Down Expand Up @@ -577,15 +677,21 @@ export function ConfigSidebar() {
/>
</Show>

{/* <Field name="Motion Blur">
<Slider
value={[project.cursor.motionBlur]}
onChange={(v) => setProject("cursor", "motionBlur", v[0])}
minValue={0}
maxValue={1}
step={0.001}
/>
</Field> */}
<Field name="Motion Blur">
<Slider
value={[project.cursor.motionBlur ?? 0]}
onChange={(v) =>
setProject(
"cursor",
"motionBlur",
Math.min(1, Math.max(0, v[0])),
)
}
minValue={0}
maxValue={1}
step={0.01}
/>
</Field>
{/* <Field name="Animation Style" icon={<IconLucideRabbit />}>
<RadioGroup
defaultValue="regular"
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 @@ -386,7 +386,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; hideWhenIdleDelay?: number; 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; shadowSize?: number; shadowBlur?: number; shadowStrength?: number }
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
21 changes: 21 additions & 0 deletions crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,12 @@ pub struct CursorConfiguration {
pub motion_blur: f32,
#[serde(default = "yes")]
pub use_svg: bool,
#[serde(default = "CursorConfiguration::default_shadow_size")]
pub shadow_size: f32,
#[serde(default = "CursorConfiguration::default_shadow_blur")]
pub shadow_blur: f32,
#[serde(default = "CursorConfiguration::default_shadow_opacity")]
pub shadow_strength: f32,
}

fn yes() -> bool {
Expand All @@ -435,6 +441,9 @@ impl Default for CursorConfiguration {
raw: false,
motion_blur: 0.5,
use_svg: true,
shadow_size: Self::default_shadow_size(),
shadow_blur: Self::default_shadow_blur(),
shadow_strength: Self::default_shadow_opacity(),
}
}
}
Expand All @@ -446,6 +455,18 @@ impl CursorConfiguration {
fn default_hide_when_idle_delay() -> f32 {
2.0
}

fn default_shadow_size() -> f32 {
1.0
}

fn default_shadow_blur() -> f32 {
0.8
}

fn default_shadow_opacity() -> f32 {
0.25
}
}

#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)]
Expand Down
143 changes: 129 additions & 14 deletions crates/rendering/src/cursor_interpolation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,89 @@ pub fn interpolate_cursor(
let events = get_smoothed_cursor_events(&cursor.moves, smoothing_config);
interpolate_smoothed_position(&events, time_secs as f64, smoothing_config)
} else {
let (pos, cursor_id) = cursor.moves.windows(2).find_map(|chunk| {
if time_ms >= chunk[0].time_ms && time_ms < chunk[1].time_ms {
let c = &chunk[0];
Some((XY::new(c.x as f32, c.y as f32), c.cursor_id.clone()))
} else {
None
}
})?;

Some(InterpolatedCursorPosition {
interpolate_spline(&cursor.moves, time_ms)
}
}

fn interpolate_spline(
moves: &[CursorMoveEvent],
time_ms: f64,
) -> Option<InterpolatedCursorPosition> {
let (segment_index, next) = moves
.iter()
.enumerate()
.find(|(_, event)| event.time_ms >= time_ms)?;

if segment_index == 0 {
return Some(InterpolatedCursorPosition {
position: Coord::new(XY {
x: pos.x as f64,
y: pos.y as f64,
x: next.x,
y: next.y,
}),
velocity: XY::new(0.0, 0.0),
cursor_id,
})
cursor_id: next.cursor_id.clone(),
});
}

let prev_index = segment_index - 1;
let prev = &moves[prev_index];

let span_ms = (next.time_ms - prev.time_ms).max(f64::EPSILON);
let t = ((time_ms - prev.time_ms) / span_ms).clamp(0.0, 1.0);

if moves.len() < 4 {
let lerp = |a: f64, b: f64| a + (b - a) * t;
let position = Coord::new(XY {
x: lerp(prev.x, next.x),
y: lerp(prev.y, next.y),
});
let velocity = XY::new(
((next.x - prev.x) / span_ms) as f32 * 1000.0,
((next.y - prev.y) / span_ms) as f32 * 1000.0,
);

return Some(InterpolatedCursorPosition {
position,
velocity,
cursor_id: prev.cursor_id.clone(),
});
}

let p0 = moves.get(prev_index.saturating_sub(1)).unwrap_or(prev);
let p3 = moves.get(segment_index + 1).unwrap_or(next);

let (x, dx_dt) = catmull_rom(p0.x, prev.x, next.x, p3.x, t);
let (y, dy_dt) = catmull_rom(p0.y, prev.y, next.y, p3.y, t);

let mut position = Coord::new(XY { x, y });
position.coord.x = position.coord.x.clamp(0.0, 1.0);
position.coord.y = position.coord.y.clamp(0.0, 1.0);

let velocity = XY::new(
((dx_dt / span_ms) * 1000.0) as f32,
((dy_dt / span_ms) * 1000.0) as f32,
);

Some(InterpolatedCursorPosition {
position,
velocity,
cursor_id: prev.cursor_id.clone(),
})
}

fn catmull_rom(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> (f64, f64) {
let t2 = t * t;
let t3 = t2 * t;

let a = 2.0 * p1;
let b = -p0 + p2;
let c = 2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3;
let d = -p0 + 3.0 * p1 - 3.0 * p2 + p3;

let position = 0.5 * (a + b * t + c * t2 + d * t3);
let derivative = 0.5 * (b + 2.0 * c * t + 3.0 * d * t2);

(position, derivative)
}

fn get_smoothed_cursor_events(
Expand Down Expand Up @@ -169,3 +234,53 @@ struct SmoothedCursorEvent {
velocity: XY<f32>,
cursor_id: String,
}

#[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,
}
}

#[test]
fn linear_fallback_blends_positions() {
let moves = vec![move_event(0.0, 0.0, 0.0), move_event(10.0, 1.0, 1.0)];

let interpolated = interpolate_spline(&moves, 5.0).expect("interpolated cursor");

assert!((interpolated.position.x - 0.5).abs() < 1e-6);
assert!((interpolated.position.y - 0.5).abs() < 1e-6);
}

#[test]
fn linear_fallback_computes_velocity() {
let moves = vec![move_event(0.0, 0.0, 0.0), move_event(20.0, 1.0, -1.0)];

let interpolated = interpolate_spline(&moves, 10.0).expect("interpolated cursor");

assert!((interpolated.velocity.x - 50.0).abs() < 1e-3);
assert!((interpolated.velocity.y + 50.0).abs() < 1e-3);
}

#[test]
fn spline_uses_neighbors_for_smoothing() {
let moves = vec![
move_event(0.0, 0.0, 0.0),
move_event(10.0, 0.5, 0.5),
move_event(20.0, 1.0, 1.0),
move_event(30.0, 1.5, 1.5),
];

let interpolated = interpolate_spline(&moves, 15.0).expect("interpolated cursor");

assert!(interpolated.position.x > 0.5);
assert!(interpolated.position.x < 1.0);
}
}
Loading
Loading