From d380e549cd639e602ee95defbd52d083c9201465 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 1 May 2026 14:22:09 -0400 Subject: [PATCH] feat(editor): add cursor opacity and circle color controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two configurable cursor styles to the Studio Mode editor: - Opacity slider (0-200%, default 100%) — multiplied into the existing shader uniform; affects every cursor type. - Circle Color picker (default #FFFFFF) — tints the baked Circle cursor texture; only shown when Cursor Type is Circle. Schema (CursorConfiguration) gains two fields with serde defaults so existing project files load unchanged. --- .../src/routes/editor/ConfigSidebar.tsx | 30 ++++++++++++++ apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/configuration.rs | 14 +++++++ crates/rendering/src/layers/cursor.rs | 40 ++++++++++++++----- packages/ui-solid/src/auto-imports.d.ts | 1 + 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 4344bc09c7..c95d9b6bdc 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -651,6 +651,36 @@ export function ConfigSidebar() { step={1} /> + }> + setProject("cursor", "cursorOpacity", v[0])} + minValue={0} + maxValue={2} + step={0.01} + formatTooltip={(value) => `${Math.round(value * 100)}%`} + /> + + + } + value={ + + setProject( + "cursor", + "circleColor", + e.currentTarget.value.toUpperCase(), + ) + } + class="h-7 w-10 cursor-pointer rounded border border-gray-3 bg-transparent" + /> + } + /> + }> ; shape?: string | null } export type CursorType = "auto" | "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 e6607e5c18..7fe630f3c0 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -544,6 +544,10 @@ pub struct CursorConfiguration { pub rotation_amount: f32, #[serde(default)] pub base_rotation: f32, + #[serde(default = "CursorConfiguration::default_cursor_opacity")] + pub cursor_opacity: f32, + #[serde(default = "CursorConfiguration::default_circle_color")] + pub circle_color: String, #[serde(default)] pub click_spring: Option, #[serde(default)] @@ -568,6 +572,8 @@ impl Default for CursorConfiguration { use_svg: true, rotation_amount: Self::default_rotation_amount(), base_rotation: 0.0, + cursor_opacity: Self::default_cursor_opacity(), + circle_color: Self::default_circle_color(), click_spring: None, stop_movement_in_last_seconds: None, }; @@ -590,6 +596,14 @@ impl CursorConfiguration { 0.15 } + fn default_cursor_opacity() -> f32 { + 1.0 + } + + fn default_circle_color() -> String { + "#FFFFFF".to_string() + } + pub fn cursor_type(&self) -> &CursorType { &self.r#type } diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index 4ef81e5bb5..983f576577 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -37,6 +37,7 @@ pub struct CursorLayer { circle_cursor: Option, prev_is_svg_assets_enabled: Option, prev_cursor_type: Option, + prev_circle_color: Option, } struct Statics { @@ -185,6 +186,18 @@ impl Statics { } } +fn parse_hex_color(hex: &str) -> [f32; 3] { + let stripped = hex.strip_prefix('#').unwrap_or(hex); + if stripped.len() != 6 { + return [1.0, 1.0, 1.0]; + } + let parse = |i: usize| u8::from_str_radix(&stripped[i..i + 2], 16); + match (parse(0), parse(2), parse(4)) { + (Ok(r), Ok(g), Ok(b)) => [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0], + _ => [1.0, 1.0, 1.0], + } +} + impl CursorLayer { pub fn new(device: &wgpu::Device) -> Self { let statics = Statics::new(device); @@ -196,10 +209,11 @@ impl CursorLayer { circle_cursor: None, prev_is_svg_assets_enabled: None, prev_cursor_type: None, + prev_circle_color: None, } } - fn create_circle_cursor(constants: &RenderVideoConstants) -> CursorTexture { + fn create_circle_cursor(constants: &RenderVideoConstants, color: [f32; 3]) -> CursorTexture { let size = CIRCLE_CURSOR_SIZE; let mut rgba = vec![0u8; (size * size * 4) as usize]; let center = size as f32 / 2.0; @@ -210,6 +224,8 @@ impl CursorLayer { let fill_alpha = 0.2_f32; let border_alpha = 0.55_f32; + let [r, g, b] = color; + for y in 0..size { for x in 0..size { let dx = x as f32 - center + 0.5; @@ -230,11 +246,10 @@ impl CursorLayer { let base_alpha = fill_alpha + border_factor * (border_alpha - fill_alpha); let alpha = base_alpha * outer_fade; - let premul = (255.0 * alpha) as u8; - rgba[idx] = premul; - rgba[idx + 1] = premul; - rgba[idx + 2] = premul; - rgba[idx + 3] = premul; + rgba[idx] = ((r * 255.0) * alpha) as u8; + rgba[idx + 1] = ((g * 255.0) * alpha) as u8; + rgba[idx + 2] = ((b * 255.0) * alpha) as u8; + rgba[idx + 3] = (255.0 * alpha) as u8; } } } @@ -323,7 +338,7 @@ impl CursorLayer { XY::new(0.0, 0.0) }; - let mut cursor_opacity = 1.0f32; + let mut cursor_opacity = uniforms.project.cursor.cursor_opacity.max(0.0); if uniforms.project.cursor.hide_when_idle && !cursor.moves.is_empty() { let hide_delay_secs = uniforms .project @@ -331,7 +346,7 @@ impl CursorLayer { .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_opacity *= compute_cursor_idle_opacity( cursor, segment_frames.recording_time as f64 * 1000.0, hide_delay_ms, @@ -348,6 +363,12 @@ impl CursorLayer { self.circle_cursor = None; } + let circle_color_str = uniforms.project.cursor.circle_color.clone(); + if self.prev_circle_color.as_deref() != Some(circle_color_str.as_str()) { + self.prev_circle_color = Some(circle_color_str.clone()); + self.circle_cursor = None; + } + if self.prev_is_svg_assets_enabled != Some(uniforms.project.cursor.use_svg) { self.prev_is_svg_assets_enabled = Some(uniforms.project.cursor.use_svg); self.cursors.drain(); @@ -355,7 +376,8 @@ impl CursorLayer { let cursor_texture = if cursor_type == CursorType::Circle { if self.circle_cursor.is_none() { - self.circle_cursor = Some(Self::create_circle_cursor(constants)); + let color = parse_hex_color(&circle_color_str); + self.circle_cursor = Some(Self::create_circle_cursor(constants, color)); } self.circle_cursor.as_ref().unwrap() } else { diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 2513f98d22..340854dd95 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -75,6 +75,7 @@ declare global { const IconLucideCircleOff: typeof import('~icons/lucide/circle-off.jsx')['default'] const IconLucideClapperboard: typeof import('~icons/lucide/clapperboard.jsx')['default'] const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] + const IconLucideDroplet: typeof import('~icons/lucide/droplet.jsx')['default'] const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] const IconLucideEyeOff: typeof import('~icons/lucide/eye-off.jsx')['default'] const IconLucideFastForward: typeof import('~icons/lucide/fast-forward.jsx')['default']