Skip to content

Commit cba5cb3

Browse files
feat: Add circle cursor option and improve cursor rendering
This commit introduces a new "Circle" cursor type, providing an alternative to the default "Auto" cursor. It also refactors cursor rendering logic to better handle different cursor types and improve performance. Co-authored-by: richiemcilroy1 <richiemcilroy1@gmail.com>
1 parent d2c2804 commit cba5cb3

File tree

5 files changed

+162
-61
lines changed

5 files changed

+162
-61
lines changed

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
type CameraShape,
4747
type ClipOffsets,
4848
type CursorAnimationStyle,
49+
type CursorType,
4950
commands,
5051
type SceneSegment,
5152
type StereoMode,
@@ -238,6 +239,19 @@ type CursorPresetValues = {
238239

239240
const DEFAULT_CURSOR_MOTION_BLUR = 0.5;
240241

242+
const CURSOR_TYPE_OPTIONS = [
243+
{
244+
value: "auto" as CursorType,
245+
label: "Auto",
246+
description: "Uses the actual cursor from your recording.",
247+
},
248+
{
249+
value: "circle" as CursorType,
250+
label: "Circle",
251+
description: "A touch-style circle cursor like mobile simulators.",
252+
},
253+
];
254+
241255
const CURSOR_ANIMATION_STYLE_OPTIONS = [
242256
{
243257
value: "slow",
@@ -580,6 +594,35 @@ export function ConfigSidebar() {
580594
}
581595
/>
582596
<Show when={!project.cursor.hide}>
597+
<Field name="Cursor Type" icon={<IconCapCursor />}>
598+
<RadioGroup
599+
class="flex flex-col gap-2"
600+
value={project.cursor.type}
601+
onChange={(value) =>
602+
setProject("cursor", "type", value as CursorType)
603+
}
604+
>
605+
{CURSOR_TYPE_OPTIONS.map((option) => (
606+
<RadioGroup.Item
607+
value={option.value}
608+
class="rounded-lg border border-gray-3 transition-colors ui-checked:border-blue-8 ui-checked:bg-blue-3/40"
609+
>
610+
<RadioGroup.ItemInput class="sr-only" />
611+
<RadioGroup.ItemLabel class="flex cursor-pointer items-start gap-3 p-3">
612+
<RadioGroup.ItemControl class="mt-1 size-4 rounded-full border border-gray-7 ui-checked:border-blue-9 ui-checked:bg-blue-9" />
613+
<div class="flex flex-col text-left">
614+
<span class="text-sm font-medium text-gray-12">
615+
{option.label}
616+
</span>
617+
<span class="text-xs text-gray-11">
618+
{option.description}
619+
</span>
620+
</div>
621+
</RadioGroup.ItemLabel>
622+
</RadioGroup.Item>
623+
))}
624+
</RadioGroup>
625+
</Field>
583626
<Field name="Size" icon={<IconCapEnlarge />}>
584627
<Slider
585628
value={[project.cursor.size]}

apps/desktop/src/routes/screenshot-editor/context.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const DEFAULT_CURSOR: CursorConfiguration = {
5454
hideWhenIdle: false,
5555
hideWhenIdleDelay: 2,
5656
size: 100,
57-
type: "pointer",
57+
type: "auto",
5858
animationStyle: "mellow",
5959
tension: 120,
6060
mass: 1.1,

crates/project/src/configuration.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,10 +394,11 @@ impl Default for AudioConfiguration {
394394
}
395395
}
396396

397-
#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)]
397+
#[derive(Type, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
398398
#[serde(rename_all = "camelCase")]
399399
pub enum CursorType {
400400
#[default]
401+
Auto,
401402
Pointer,
402403
Circle,
403404
}

crates/project/src/meta.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ impl Default for Platform {
5555
#[cfg(windows)]
5656
return Self::Windows;
5757

58-
#[cfg(target_os = "macos")]
58+
#[cfg(not(windows))]
5959
return Self::MacOS;
6060
}
6161
}

crates/rendering/src/layers/cursor.rs

Lines changed: 115 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ const CURSOR_MAX_STRENGTH: f32 = 5.0;
2626
/// The size to render the svg to.
2727
static SVG_CURSOR_RASTERIZED_HEIGHT: u32 = 200;
2828

29+
const CIRCLE_CURSOR_SIZE: u32 = 64;
30+
2931
pub struct CursorLayer {
3032
statics: Statics,
3133
bind_group: Option<BindGroup>,
3234
cursors: HashMap<String, CursorTexture>,
35+
circle_cursor: Option<CursorTexture>,
3336
prev_is_svg_assets_enabled: Option<bool>,
37+
prev_cursor_type: Option<CursorType>,
3438
}
3539

3640
struct Statics {
@@ -187,8 +191,51 @@ impl CursorLayer {
187191
statics,
188192
bind_group: None,
189193
cursors: Default::default(),
194+
circle_cursor: None,
190195
prev_is_svg_assets_enabled: None,
196+
prev_cursor_type: None,
197+
}
198+
}
199+
200+
fn create_circle_cursor(constants: &RenderVideoConstants) -> CursorTexture {
201+
let size = CIRCLE_CURSOR_SIZE;
202+
let mut rgba = vec![0u8; (size * size * 4) as usize];
203+
let center = size as f32 / 2.0;
204+
let outer_radius = center - 2.0;
205+
let inner_radius = outer_radius - 4.0;
206+
207+
for y in 0..size {
208+
for x in 0..size {
209+
let dx = x as f32 - center;
210+
let dy = y as f32 - center;
211+
let dist = (dx * dx + dy * dy).sqrt();
212+
let idx = ((y * size + x) * 4) as usize;
213+
214+
if dist <= outer_radius && dist >= inner_radius {
215+
let edge_softness = 1.5;
216+
let outer_alpha = 1.0
217+
- ((dist - outer_radius + edge_softness) / edge_softness).clamp(0.0, 1.0);
218+
let inner_alpha = ((dist - inner_radius) / edge_softness).clamp(0.0, 1.0);
219+
let alpha = (outer_alpha * inner_alpha * 255.0) as u8;
220+
221+
rgba[idx] = 80;
222+
rgba[idx + 1] = 80;
223+
rgba[idx + 2] = 80;
224+
rgba[idx + 3] = alpha;
225+
} else if dist < inner_radius {
226+
let fill_alpha = 0.15;
227+
let edge_alpha = ((inner_radius - dist) / 2.0).clamp(0.0, 1.0);
228+
let alpha = (fill_alpha * edge_alpha * 255.0) as u8;
229+
230+
rgba[idx] = 128;
231+
rgba[idx + 1] = 128;
232+
rgba[idx + 2] = 128;
233+
rgba[idx + 3] = alpha;
234+
}
235+
}
191236
}
237+
238+
CursorTexture::prepare(constants, &rgba, (size, size), XY::new(0.5, 0.5))
192239
}
193240

194241
pub fn prepare(
@@ -270,71 +317,81 @@ impl CursorLayer {
270317
}
271318
}
272319

273-
// Remove all cursor assets if the svg configuration changes.
274-
// it might change the texture.
275-
//
276-
// This would be better if it only invalidated the required assets but that would be more complicated.
320+
let cursor_type = uniforms.project.cursor.r#type.clone();
321+
322+
if self.prev_cursor_type.as_ref() != Some(&cursor_type) {
323+
self.prev_cursor_type = Some(cursor_type.clone());
324+
self.circle_cursor = None;
325+
}
326+
277327
if self.prev_is_svg_assets_enabled != Some(uniforms.project.cursor.use_svg) {
278328
self.prev_is_svg_assets_enabled = Some(uniforms.project.cursor.use_svg);
279329
self.cursors.drain();
280330
}
281331

282-
if !self.cursors.contains_key(&interpolated_cursor.cursor_id) {
283-
let mut cursor = None;
284-
285-
let cursor_shape = match &constants.recording_meta.inner {
286-
RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments {
287-
inner:
288-
MultipleSegments {
289-
cursors: Cursors::Correct(cursors),
290-
..
291-
},
292-
}) => cursors
293-
.get(&interpolated_cursor.cursor_id)
294-
.and_then(|v| v.shape),
295-
_ => None,
296-
};
297-
298-
// Attempt to find and load a higher-quality SVG cursor included in Cap.
299-
// These are used instead of the OS provided cursor images when possible as the quality is better.
300-
if let Some(cursor_shape) = cursor_shape
301-
&& uniforms.project.cursor.use_svg
302-
&& let Some(info) = cursor_shape.resolve()
303-
{
304-
cursor = CursorTexture::prepare_svg(constants, info.raw, info.hotspot.into())
305-
.map_err(|err| {
306-
error!(
307-
"Error loading SVG cursor {:?}: {err}",
308-
interpolated_cursor.cursor_id
309-
)
310-
})
311-
.ok();
332+
let cursor_texture = if cursor_type == CursorType::Circle {
333+
if self.circle_cursor.is_none() {
334+
self.circle_cursor = Some(Self::create_circle_cursor(constants));
312335
}
313-
314-
// If not we attempt to load the low-quality image cursor
315-
if let StudioRecordingMeta::MultipleSegments { inner, .. } = &constants.meta
316-
&& cursor.is_none()
317-
&& let Some(c) = inner
318-
.get_cursor_image(&constants.recording_meta, &interpolated_cursor.cursor_id)
319-
&& let Ok(img) = image::open(&c.path)
320-
.map_err(|err| error!("Failed to load cursor image from {:?}: {err}", c.path))
321-
{
322-
cursor = Some(CursorTexture::prepare(
323-
constants,
324-
&img.to_rgba8(),
325-
img.dimensions(),
326-
c.hotspot,
327-
));
328-
}
329-
330-
if let Some(cursor) = cursor {
331-
self.cursors
332-
.insert(interpolated_cursor.cursor_id.clone(), cursor);
336+
self.circle_cursor.as_ref().unwrap()
337+
} else {
338+
if !self.cursors.contains_key(&interpolated_cursor.cursor_id) {
339+
let mut loaded_cursor = None;
340+
341+
let cursor_shape = match &constants.recording_meta.inner {
342+
RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments {
343+
inner:
344+
MultipleSegments {
345+
cursors: Cursors::Correct(cursors),
346+
..
347+
},
348+
}) => cursors
349+
.get(&interpolated_cursor.cursor_id)
350+
.and_then(|v| v.shape),
351+
_ => None,
352+
};
353+
354+
if let Some(cursor_shape) = cursor_shape
355+
&& uniforms.project.cursor.use_svg
356+
&& let Some(info) = cursor_shape.resolve()
357+
{
358+
loaded_cursor =
359+
CursorTexture::prepare_svg(constants, info.raw, info.hotspot.into())
360+
.map_err(|err| {
361+
error!(
362+
"Error loading SVG cursor {:?}: {err}",
363+
interpolated_cursor.cursor_id
364+
)
365+
})
366+
.ok();
367+
}
368+
369+
if let StudioRecordingMeta::MultipleSegments { inner, .. } = &constants.meta
370+
&& loaded_cursor.is_none()
371+
&& let Some(c) = inner
372+
.get_cursor_image(&constants.recording_meta, &interpolated_cursor.cursor_id)
373+
&& let Ok(img) = image::open(&c.path).map_err(|err| {
374+
error!("Failed to load cursor image from {:?}: {err}", c.path)
375+
})
376+
{
377+
loaded_cursor = Some(CursorTexture::prepare(
378+
constants,
379+
&img.to_rgba8(),
380+
img.dimensions(),
381+
c.hotspot,
382+
));
383+
}
384+
385+
if let Some(c) = loaded_cursor {
386+
self.cursors
387+
.insert(interpolated_cursor.cursor_id.clone(), c);
388+
}
333389
}
334-
}
335-
let Some(cursor_texture) = self.cursors.get(&interpolated_cursor.cursor_id) else {
336-
error!("Cursor {:?} not found!", interpolated_cursor.cursor_id);
337-
return;
390+
let Some(tex) = self.cursors.get(&interpolated_cursor.cursor_id) else {
391+
error!("Cursor {:?} not found!", interpolated_cursor.cursor_id);
392+
return;
393+
};
394+
tex
338395
};
339396

340397
let size = {

0 commit comments

Comments
 (0)