From a1eb92a5e226f156114150813955972bdf40c932 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:37:51 +0100 Subject: [PATCH 01/10] feat: map cursor shapes across platform styles --- crates/cursor-info/src/lib.rs | 210 ++++++++++++++++++++++++++++++ crates/cursor-info/src/macos.rs | 78 ++++++++++- crates/cursor-info/src/windows.rs | 19 ++- 3 files changed, 305 insertions(+), 2 deletions(-) diff --git a/crates/cursor-info/src/lib.rs b/crates/cursor-info/src/lib.rs index 0c22d857064..8dce64db8db 100644 --- a/crates/cursor-info/src/lib.rs +++ b/crates/cursor-info/src/lib.rs @@ -102,3 +102,213 @@ impl Type for CursorShape { String::inline(types, generics) } } + +/// A visual family of cursor assets. Any recording can be re-rendered in any +/// family by cross-mapping its recorded shapes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CursorFamily { + MacOS, + MacOSTahoe, + Windows, +} + +impl CursorFamily { + pub fn arrow(self) -> CursorShape { + match self { + Self::MacOS => CursorShape::MacOS(CursorShapeMacOS::Arrow), + Self::MacOSTahoe => CursorShape::MacOS(CursorShapeMacOS::TahoeArrow), + Self::Windows => CursorShape::Windows(CursorShapeWindows::Arrow), + } + } +} + +impl CursorShape { + pub fn family(self) -> CursorFamily { + match self { + Self::MacOS(cursor) => { + if cursor.is_tahoe() { + CursorFamily::MacOSTahoe + } else { + CursorFamily::MacOS + } + } + Self::Windows(_) => CursorFamily::Windows, + } + } + + /// The equivalent shape in another family; identity within the family. + /// Shapes with no counterpart, and any whose asset is missing, fall back + /// to the target family's arrow so the renderer always has something to + /// draw. + pub fn in_family(self, family: CursorFamily) -> CursorShape { + let mapped = match (self, family) { + (Self::MacOS(cursor), CursorFamily::MacOS) => Self::MacOS(cursor.to_classic()), + (Self::MacOS(cursor), CursorFamily::MacOSTahoe) => Self::MacOS(cursor.to_tahoe()), + (Self::MacOS(cursor), CursorFamily::Windows) => { + Self::Windows(cursor.to_classic().to_windows()) + } + (Self::Windows(cursor), CursorFamily::Windows) => Self::Windows(cursor), + (Self::Windows(cursor), CursorFamily::MacOS) => Self::MacOS(cursor.to_macos()), + (Self::Windows(cursor), CursorFamily::MacOSTahoe) => { + Self::MacOS(cursor.to_macos().to_tahoe()) + } + }; + + if mapped.resolve().is_none() { + family.arrow() + } else { + mapped + } + } +} + +#[cfg(test)] +mod family_tests { + use super::*; + use strum::IntoEnumIterator; + + fn all_shapes() -> Vec { + CursorShapeMacOS::iter() + .map(CursorShape::MacOS) + .chain(CursorShapeWindows::iter().map(CursorShape::Windows)) + .collect() + } + + #[test] + fn family_classifies_every_shape() { + for shape in all_shapes() { + let family = shape.family(); + match shape { + CursorShape::Windows(_) => assert_eq!(family, CursorFamily::Windows), + CursorShape::MacOS(cursor) => { + let name: &'static str = cursor.into(); + if name.starts_with("Tahoe") { + assert_eq!(family, CursorFamily::MacOSTahoe, "{shape}"); + } else { + assert_eq!(family, CursorFamily::MacOS, "{shape}"); + } + } + } + } + } + + #[test] + fn in_family_always_resolves() { + for shape in all_shapes() { + for family in [ + CursorFamily::MacOS, + CursorFamily::MacOSTahoe, + CursorFamily::Windows, + ] { + let mapped = shape.in_family(family); + assert_eq!(mapped.family(), family, "{shape} -> {family:?}"); + assert!( + mapped.resolve().is_some(), + "{shape} -> {family:?} produced unresolvable {mapped}" + ); + } + } + } + + #[test] + fn in_family_is_identity_within_family() { + for shape in all_shapes() { + if shape.resolve().is_none() { + continue; + } + assert_eq!(shape.in_family(shape.family()), shape, "{shape}"); + } + } + + #[test] + fn unresolvable_shapes_become_the_family_arrow() { + for shape in [ + CursorShape::MacOS(CursorShapeMacOS::DisappearingItem), + CursorShape::MacOS(CursorShapeMacOS::TahoeDisappearingItem), + CursorShape::Windows(CursorShapeWindows::ArrowCD), + ] { + assert_eq!( + shape.in_family(shape.family()), + shape.family().arrow(), + "{shape}" + ); + } + } + + #[test] + fn windows_macos_round_trips_are_exact() { + let pairs = [ + (CursorShapeWindows::Arrow, CursorShapeMacOS::Arrow), + (CursorShapeWindows::IBeam, CursorShapeMacOS::IBeam), + (CursorShapeWindows::Hand, CursorShapeMacOS::PointingHand), + (CursorShapeWindows::Cross, CursorShapeMacOS::Crosshair), + ( + CursorShapeWindows::No, + CursorShapeMacOS::OperationNotAllowed, + ), + ( + CursorShapeWindows::SizeWE, + CursorShapeMacOS::ResizeLeftRight, + ), + (CursorShapeWindows::SizeNS, CursorShapeMacOS::ResizeUpDown), + (CursorShapeWindows::SizeAll, CursorShapeMacOS::OpenHand), + ]; + + for (win, mac) in pairs { + let win = CursorShape::Windows(win); + let mac = CursorShape::MacOS(mac); + assert_eq!(win.in_family(CursorFamily::MacOS), mac, "{win} -> macos"); + assert_eq!( + mac.in_family(CursorFamily::Windows), + win, + "{mac} -> windows" + ); + assert_eq!( + win.in_family(CursorFamily::MacOS) + .in_family(CursorFamily::Windows), + win, + "{win} round trip" + ); + } + } + + #[test] + fn tahoe_round_trips_by_name() { + let mac = CursorShape::MacOS(CursorShapeMacOS::PointingHand); + let tahoe = CursorShape::MacOS(CursorShapeMacOS::TahoePointingHand); + + assert_eq!(mac.in_family(CursorFamily::MacOSTahoe), tahoe); + assert_eq!(tahoe.in_family(CursorFamily::MacOS), mac); + assert_eq!( + tahoe.in_family(CursorFamily::Windows), + CursorShape::Windows(CursorShapeWindows::Hand) + ); + } + + #[test] + fn tahoe_only_shapes_fall_back_to_classic_arrow() { + let zoom = CursorShape::MacOS(CursorShapeMacOS::TahoeZoomIn); + + assert_eq!( + zoom.in_family(CursorFamily::MacOS), + CursorShape::MacOS(CursorShapeMacOS::Arrow) + ); + assert_eq!( + zoom.in_family(CursorFamily::Windows), + CursorShape::Windows(CursorShapeWindows::Arrow) + ); + } + + #[test] + fn every_family_arrow_resolves() { + for family in [ + CursorFamily::MacOS, + CursorFamily::MacOSTahoe, + CursorFamily::Windows, + ] { + let arrow = family.arrow(); + assert_eq!(arrow.family(), family, "{arrow}"); + assert!(arrow.resolve().is_some(), "{arrow} has no asset"); + } + } +} diff --git a/crates/cursor-info/src/macos.rs b/crates/cursor-info/src/macos.rs index c0fa534035e..d8cc28fff14 100644 --- a/crates/cursor-info/src/macos.rs +++ b/crates/cursor-info/src/macos.rs @@ -1,10 +1,11 @@ use strum::{EnumString, IntoStaticStr}; -use crate::{CursorShape, ResolvedCursor}; +use crate::{CursorShape, CursorShapeWindows, ResolvedCursor}; /// macOS Cursors /// https://developer.apple.com/documentation/appkit/nscursor #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)] +#[cfg_attr(test, derive(strum::EnumIter))] pub enum CursorShapeMacOS { /// https://developer.apple.com/documentation/appkit/nscursor/arrow Arrow, @@ -337,3 +338,78 @@ impl From for CursorShape { CursorShape::MacOS(value) } } + +impl CursorShapeMacOS { + pub(crate) fn is_tahoe(self) -> bool { + self.to_classic() != self + } + + /// The pre-Tahoe variant of a shape; identity for shapes that already are + /// one, and `Arrow` for the Tahoe-only additions. + pub(crate) fn to_classic(self) -> Self { + match self { + Self::TahoeArrow => Self::Arrow, + Self::TahoeContextualMenu => Self::ContextualMenu, + Self::TahoeClosedHand => Self::ClosedHand, + Self::TahoeCrosshair => Self::Crosshair, + Self::TahoeDisappearingItem => Self::DisappearingItem, + Self::TahoeDragCopy => Self::DragCopy, + Self::TahoeDragLink => Self::DragLink, + Self::TahoeIBeam => Self::IBeam, + Self::TahoeOpenHand => Self::OpenHand, + Self::TahoeOperationNotAllowed => Self::OperationNotAllowed, + Self::TahoePointingHand => Self::PointingHand, + Self::TahoeResizeDown => Self::ResizeDown, + Self::TahoeResizeLeft => Self::ResizeLeft, + Self::TahoeResizeLeftRight => Self::ResizeLeftRight, + Self::TahoeResizeRight => Self::ResizeRight, + Self::TahoeResizeUp => Self::ResizeUp, + Self::TahoeResizeUpDown => Self::ResizeUpDown, + Self::TahoeIBeamVerticalForVerticalLayout => Self::IBeamVerticalForVerticalLayout, + Self::TahoeZoomIn | Self::TahoeZoomOut => Self::Arrow, + other => other, + } + } + + /// The Tahoe variant of a shape; identity for shapes that already are one. + pub(crate) fn to_tahoe(self) -> Self { + match self { + Self::Arrow => Self::TahoeArrow, + Self::ContextualMenu => Self::TahoeContextualMenu, + Self::ClosedHand => Self::TahoeClosedHand, + Self::Crosshair => Self::TahoeCrosshair, + Self::DisappearingItem => Self::TahoeDisappearingItem, + Self::DragCopy => Self::TahoeDragCopy, + Self::DragLink => Self::TahoeDragLink, + Self::IBeam => Self::TahoeIBeam, + Self::OpenHand => Self::TahoeOpenHand, + Self::OperationNotAllowed => Self::TahoeOperationNotAllowed, + Self::PointingHand => Self::TahoePointingHand, + Self::ResizeDown => Self::TahoeResizeDown, + Self::ResizeLeft => Self::TahoeResizeLeft, + Self::ResizeLeftRight => Self::TahoeResizeLeftRight, + Self::ResizeRight => Self::TahoeResizeRight, + Self::ResizeUp => Self::TahoeResizeUp, + Self::ResizeUpDown => Self::TahoeResizeUpDown, + Self::IBeamVerticalForVerticalLayout => Self::TahoeIBeamVerticalForVerticalLayout, + other => other, + } + } + + /// The closest Windows counterpart. Only meaningful for classic variants; + /// call `to_classic` first. + pub(crate) fn to_windows(self) -> CursorShapeWindows { + match self { + Self::IBeam | Self::IBeamVerticalForVerticalLayout => CursorShapeWindows::IBeam, + Self::PointingHand => CursorShapeWindows::Hand, + Self::Crosshair => CursorShapeWindows::Cross, + Self::OperationNotAllowed => CursorShapeWindows::No, + Self::ResizeLeft | Self::ResizeRight | Self::ResizeLeftRight => { + CursorShapeWindows::SizeWE + } + Self::ResizeUp | Self::ResizeDown | Self::ResizeUpDown => CursorShapeWindows::SizeNS, + Self::OpenHand | Self::ClosedHand => CursorShapeWindows::SizeAll, + _ => CursorShapeWindows::Arrow, + } + } +} diff --git a/crates/cursor-info/src/windows.rs b/crates/cursor-info/src/windows.rs index 7461c3f0696..96ca60a087e 100644 --- a/crates/cursor-info/src/windows.rs +++ b/crates/cursor-info/src/windows.rs @@ -1,9 +1,10 @@ use strum::{EnumString, IntoStaticStr}; -use crate::{CursorShape, ResolvedCursor}; +use crate::{CursorShape, CursorShapeMacOS, ResolvedCursor}; // https://learn.microsoft.com/en-us/windows/win32/menurc/about-cursors #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumString, IntoStaticStr)] +#[cfg_attr(test, derive(strum::EnumIter))] pub enum CursorShapeWindows { /// IDC_ARROW Arrow, @@ -269,3 +270,19 @@ impl From for CursorShape { CursorShape::Windows(value) } } + +impl CursorShapeWindows { + /// The closest classic (pre-Tahoe) macOS counterpart. + pub(crate) fn to_macos(self) -> CursorShapeMacOS { + match self { + Self::IBeam => CursorShapeMacOS::IBeam, + Self::Hand => CursorShapeMacOS::PointingHand, + Self::Cross => CursorShapeMacOS::Crosshair, + Self::No => CursorShapeMacOS::OperationNotAllowed, + Self::SizeWE => CursorShapeMacOS::ResizeLeftRight, + Self::SizeNS => CursorShapeMacOS::ResizeUpDown, + Self::SizeAll => CursorShapeMacOS::OpenHand, + _ => CursorShapeMacOS::Arrow, + } + } +} From 2dbb7fb005d7edf7f704c0f13a63f0c022d1cdf2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:37:51 +0100 Subject: [PATCH 02/10] feat: persist cursor styles and click ripple settings --- apps/desktop/src/utils/tauri.ts | 5 +- crates/project/src/configuration.rs | 178 ++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 82300820c71..fda893d1998 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -915,9 +915,10 @@ export type CurrentRecording = { target: CurrentRecordingTarget; mode: Recording export type CurrentRecordingChanged = null export type CurrentRecordingTarget = { window: { id: WindowId; bounds: LogicalBounds | null } } | { screen: { id: DisplayId } } | { area: { screen: DisplayId; bounds: LogicalBounds } } | "camera" export type CursorAnimationStyle = "slow" | "smooth" | "mellow" | "fast" | "custom" -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; rotationAmount?: number; baseRotation?: number; clickSpring?: ClickSpringConfig | null; stopMovementInLastSeconds?: number | null } +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; rotationAmount?: number; baseRotation?: number; clickSpring?: ClickSpringConfig | null; stopMovementInLastSeconds?: number | null; ripple?: CursorRippleConfig } export type CursorMeta = { imagePath: string; hotspot: XY; shape?: string | null } -export type CursorType = "auto" | "pointer" | "circle" +export type CursorRippleConfig = { enabled: boolean; color: [number, number, number]; strength: number; size: number; duration: number } +export type CursorType = "auto" | "pointer" | "circle" | "macos" | "tahoe" | "windows" export type Cursors = { [key in string]: string } | { [key in string]: CursorMeta } export type DeviceOrModelID = { DeviceID: string } | { ModelID: ModelIDType } export type DevicesUpdated = { cameras: CameraInfo[]; microphones: string[]; permissions: OSPermissionsCheck } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 83c710b0005..8114d15523c 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -6,6 +6,7 @@ use std::{ sync::LazyLock, }; +use cap_cursor_info::CursorFamily; use serde::{Deserialize, Serialize}; use serde_json::Value; use specta::Type; @@ -646,8 +647,79 @@ impl Default for AudioConfiguration { pub enum CursorType { #[default] Auto, + // Legacy, unused by the renderer; kept so old configs keep loading. Pointer, Circle, + #[serde(rename = "macos")] + MacOS, + #[serde(rename = "tahoe")] + MacOSTahoe, + Windows, +} + +impl CursorType { + /// The asset family an explicit selection forces; `None` renders exactly + /// what was recorded. + pub fn family(&self) -> Option { + match self { + Self::MacOS => Some(CursorFamily::MacOS), + Self::MacOSTahoe => Some(CursorFamily::MacOSTahoe), + Self::Windows => Some(CursorFamily::Windows), + Self::Auto | Self::Pointer | Self::Circle => None, + } + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct CursorRippleConfig { + pub enabled: bool, + pub color: Color, + pub strength: f32, + pub size: f32, + pub duration: f32, +} + +impl Default for CursorRippleConfig { + fn default() -> Self { + Self { + enabled: false, + color: [71, 133, 255], + strength: Self::DEFAULT_STRENGTH, + size: Self::DEFAULT_SIZE, + duration: Self::DEFAULT_DURATION, + } + } +} + +impl CursorRippleConfig { + pub const DEFAULT_STRENGTH: f32 = 0.7; + pub const DEFAULT_SIZE: f32 = 1.0; + pub const DEFAULT_DURATION: f32 = 0.6; + + pub const STRENGTH_RANGE: (f32, f32) = (0.0, 1.0); + pub const SIZE_RANGE: (f32, f32) = (0.25, 3.0); + pub const DURATION_RANGE: (f32, f32) = (0.2, 1.5); + + pub fn strength_clamped(&self) -> f32 { + clamp_finite(self.strength, Self::STRENGTH_RANGE, Self::DEFAULT_STRENGTH) + } + + pub fn size_clamped(&self) -> f32 { + clamp_finite(self.size, Self::SIZE_RANGE, Self::DEFAULT_SIZE) + } + + pub fn duration_clamped(&self) -> f32 { + clamp_finite(self.duration, Self::DURATION_RANGE, Self::DEFAULT_DURATION) + } +} + +fn clamp_finite(value: f32, range: (f32, f32), fallback: f32) -> f32 { + if value.is_finite() { + value.clamp(range.0, range.1) + } else { + fallback + } } #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -756,6 +828,8 @@ pub struct CursorConfiguration { pub click_spring: Option, #[serde(default)] pub stop_movement_in_last_seconds: Option, + #[serde(default)] + pub ripple: CursorRippleConfig, } impl Default for CursorConfiguration { @@ -780,6 +854,7 @@ impl Default for CursorConfiguration { base_rotation: 0.0, click_spring: None, stop_movement_in_last_seconds: None, + ripple: CursorRippleConfig::default(), }; if let Some(preset) = animation_style.preset() { @@ -804,6 +879,10 @@ impl CursorConfiguration { &self.r#type } + pub fn set_cursor_type(&mut self, cursor_type: CursorType) { + self.r#type = cursor_type; + } + pub fn click_spring_config(&self) -> ClickSpringConfig { self.click_spring.unwrap_or_default() } @@ -3328,4 +3407,103 @@ mod tests { assert_eq!(spring.damping, default_spring.damping); assert_eq!(spring.mass, default_spring.mass); } + + #[test] + fn legacy_cursor_config_loads_with_ripple_defaults() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("project-config.json"), + r#"{ "cursor": { "type": "auto", "size": 120 } }"#, + ) + .unwrap(); + + let config = ProjectConfiguration::load(dir.path()).unwrap(); + + assert_eq!(*config.cursor.cursor_type(), CursorType::Auto); + assert_eq!(config.cursor.size, 120); + assert_eq!(config.cursor.ripple, CursorRippleConfig::default()); + assert!(!config.cursor.ripple.enabled); + assert_eq!(config.cursor.ripple.color, [71, 133, 255]); + } + + #[test] + fn cursor_type_families_round_trip() { + for (token, expected, family) in [ + ("macos", CursorType::MacOS, Some(CursorFamily::MacOS)), + ( + "tahoe", + CursorType::MacOSTahoe, + Some(CursorFamily::MacOSTahoe), + ), + ("windows", CursorType::Windows, Some(CursorFamily::Windows)), + ("circle", CursorType::Circle, None), + ("pointer", CursorType::Pointer, None), + ("auto", CursorType::Auto, None), + ] { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("project-config.json"), + format!(r#"{{ "cursor": {{ "type": "{token}" }} }}"#), + ) + .unwrap(); + + let config = ProjectConfiguration::load(dir.path()).unwrap(); + assert_eq!(*config.cursor.cursor_type(), expected, "{token}"); + assert_eq!(config.cursor.cursor_type().family(), family, "{token}"); + + let json = serde_json::to_value(&config.cursor).unwrap(); + assert_eq!(json["type"], token, "{token} does not re-serialise"); + } + } + + #[test] + fn set_cursor_type_replaces_the_private_field() { + let mut cursor = CursorConfiguration::default(); + assert_eq!(*cursor.cursor_type(), CursorType::Auto); + + cursor.set_cursor_type(CursorType::MacOSTahoe); + + assert_eq!(*cursor.cursor_type(), CursorType::MacOSTahoe); + } + + #[test] + fn ripple_config_clamps_hand_edited_values() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("project-config.json"), + r#"{ "cursor": { "ripple": { + "enabled": true, + "color": [10, 20, 30], + "strength": 9.0, + "size": 0.0, + "duration": 40.0 + } } }"#, + ) + .unwrap(); + + let config = ProjectConfiguration::load(dir.path()).unwrap(); + let ripple = &config.cursor.ripple; + + assert!(ripple.enabled); + assert_eq!(ripple.color, [10, 20, 30]); + assert_eq!(ripple.strength_clamped(), 1.0); + assert_eq!(ripple.size_clamped(), 0.25); + assert_eq!(ripple.duration_clamped(), 1.5); + + let nonfinite = CursorRippleConfig { + strength: f32::NAN, + size: f32::INFINITY, + duration: f32::NEG_INFINITY, + ..Default::default() + }; + assert_eq!( + nonfinite.strength_clamped(), + CursorRippleConfig::DEFAULT_STRENGTH + ); + assert_eq!(nonfinite.size_clamped(), CursorRippleConfig::DEFAULT_SIZE); + assert_eq!( + nonfinite.duration_clamped(), + CursorRippleConfig::DEFAULT_DURATION + ); + } } From ba7cf2e8e7db7e006f14ec11ff62bc19a9448df4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:37:51 +0100 Subject: [PATCH 03/10] feat: render cursor styles and click ripples --- crates/rendering/src/layers/click_ripple.rs | 266 ++++++++++++++++++ crates/rendering/src/layers/cursor.rs | 246 ++++++++++------ crates/rendering/src/layers/mod.rs | 2 + crates/rendering/src/lib.rs | 80 +++++- .../rendering/src/shaders/click-ripple.wgsl | 75 +++++ 5 files changed, 576 insertions(+), 93 deletions(-) create mode 100644 crates/rendering/src/layers/click_ripple.rs create mode 100644 crates/rendering/src/shaders/click-ripple.wgsl diff --git a/crates/rendering/src/layers/click_ripple.rs b/crates/rendering/src/layers/click_ripple.rs new file mode 100644 index 00000000000..d7e03c7ca8a --- /dev/null +++ b/crates/rendering/src/layers/click_ripple.rs @@ -0,0 +1,266 @@ +use bytemuck::{Pod, Zeroable}; +use cap_project::XY; +use wgpu::{include_wgsl, util::DeviceExt}; + +use super::cursor::{CursorPlacement, cursor_height_px}; +use crate::{Coord, FrameSpace, ProjectUniforms, RenderVideoConstants, zoom::InterpolatedZoom}; + +/// Ripples older than this are dropped rather than queued, so a burst of +/// clicks can never grow the uniform buffer. +pub const MAX_CLICK_RIPPLES: usize = 6; + +/// Ring radius relative to the un-shrunk cursor height. +const RIPPLE_RADIUS_SCALE: f32 = 1.25; + +/// Quad half-extent in ring radii. The ring's outer feather reaches +/// `r + w` = 1.16 radii at the end of the animation, so a quad of exactly +/// `2R` clips the last frames of the expansion into a squircle. +const RIPPLE_QUAD_EXTENT: f32 = 1.3; + +/// Dynamic-offset slots must be `min_uniform_buffer_offset_alignment` apart; +/// 256 is the widest alignment any backend asks for. +const SLOT_SIZE: u64 = 256; + +pub struct ClickRippleLayer { + statics: Statics, + bind_group: wgpu::BindGroup, + instance_count: u32, +} + +struct Statics { + uniform_buffer: wgpu::Buffer, + bind_group_layout: wgpu::BindGroupLayout, + render_pipeline: wgpu::RenderPipeline, +} + +impl Statics { + fn new(device: &wgpu::Device) -> Self { + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Click Ripple Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64, + ), + }, + count: None, + }], + }); + + let shader = device.create_shader_module(include_wgsl!("../shaders/click-ripple.wgsl")); + + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Click Ripple Pipeline"), + layout: Some( + &device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Click Ripple Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }), + ), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions { + constants: &[], + zero_initialize_workgroup_memory: false, + }, + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions { + constants: &[], + zero_initialize_workgroup_memory: false, + }, + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + Self { + bind_group_layout, + render_pipeline, + uniform_buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Click Ripple Uniform Buffer"), + contents: &[0u8; MAX_CLICK_RIPPLES * SLOT_SIZE as usize], + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }), + } + } +} + +impl ClickRippleLayer { + pub fn new(device: &wgpu::Device) -> Self { + let statics = Statics::new(device); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + layout: &statics.bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &statics.uniform_buffer, + offset: 0, + size: wgpu::BufferSize::new(std::mem::size_of::() as u64), + }), + }], + label: Some("Click Ripple Bind Group"), + }); + + Self { + statics, + bind_group, + instance_count: 0, + } + } + + pub fn prepare( + &mut self, + uniforms: &ProjectUniforms, + resolution_base: XY, + zoom: &InterpolatedZoom, + constants: &RenderVideoConstants, + ) { + self.instance_count = 0; + + if uniforms.click_ripples.is_empty() { + return; + } + + let ripple = &uniforms.project.cursor.ripple; + let color = [ + ripple.color[0] as f32 / 255.0, + ripple.color[1] as f32 / 255.0, + ripple.color[2] as f32 / 255.0, + ripple.strength_clamped(), + ]; + + let crop = ProjectUniforms::get_crop(&constants.options, &uniforms.project); + let display_size = + ProjectUniforms::display_size(&constants.options, &uniforms.project, resolution_base); + let radius = cursor_height_px( + constants.options.screen_size.y as f32, + crop.size.y as f32, + display_size.y as f32, + uniforms.cursor_size, + 1.0, + ) * RIPPLE_RADIUS_SCALE + * ripple.size_clamped(); + + if !radius.is_finite() || radius <= 0.0 { + return; + } + + let quad_side = (radius * 2.0 * RIPPLE_QUAD_EXTENT) as f64; + let size = Coord::::new(XY::new(quad_side, quad_side)); + let hotspot = XY::new(0.5, 0.5); + let placement = CursorPlacement { + constants, + uniforms, + resolution_base, + zoom, + }; + + let mut slots = [0u8; MAX_CLICK_RIPPLES * SLOT_SIZE as usize]; + let mut count = 0usize; + + for click_ripple in uniforms.click_ripples.iter().take(MAX_CLICK_RIPPLES) { + let position_uv = click_ripple.position.coord; + if !position_uv.x.is_finite() || !position_uv.y.is_finite() { + continue; + } + + let (position_size, opacity) = placement.map(position_uv, size, hotspot, 1.0); + if !position_size.iter().all(|v| v.is_finite()) { + continue; + } + + let slot = ClickRippleUniforms { + position_size, + output_size: [ + uniforms.output_size.0 as f32, + uniforms.output_size.1 as f32, + 0.0, + 0.0, + ], + screen_bounds: uniforms.display.target_bounds, + color, + params: [ + click_ripple.progress.clamp(0.0, 1.0), + opacity, + RIPPLE_QUAD_EXTENT, + 0.0, + ], + }; + + let offset = count * SLOT_SIZE as usize; + slots[offset..offset + std::mem::size_of::()] + .copy_from_slice(bytemuck::bytes_of(&slot)); + count += 1; + } + + if count == 0 { + return; + } + + constants.queue.write_buffer( + &self.statics.uniform_buffer, + 0, + &slots[..count * SLOT_SIZE as usize], + ); + self.instance_count = count as u32; + } + + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { + if self.instance_count == 0 { + return; + } + + pass.set_pipeline(&self.statics.render_pipeline); + for i in 0..self.instance_count { + pass.set_bind_group(0, &self.bind_group, &[i * SLOT_SIZE as u32]); + pass.draw(0..4, 0..1); + } + } +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, Pod, Zeroable, Default)] +struct ClickRippleUniforms { + position_size: [f32; 4], + output_size: [f32; 4], + screen_bounds: [f32; 4], + color: [f32; 4], + params: [f32; 4], +} diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index 148b3cfa062..715c16efef5 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -7,8 +7,9 @@ use tracing::error; use wgpu::{BindGroup, FilterMode, include_wgsl, util::DeviceExt}; use crate::{ - Coord, DecodedSegmentFrames, FrameSpace, ProjectUniforms, RenderVideoConstants, - STANDARD_CURSOR_HEIGHT, composite_frame::ColorGradeUniformParams, zoom::InterpolatedZoom, + Coord, DecodedSegmentFrames, FrameSpace, ProjectUniforms, RawDisplayUVSpace, + RenderVideoConstants, STANDARD_CURSOR_HEIGHT, composite_frame::ColorGradeUniformParams, + zoom::InterpolatedZoom, }; const CURSOR_CLICK_DURATION: f64 = 0.13; @@ -272,10 +273,11 @@ impl CursorLayer { constants: &RenderVideoConstants, cursor_id: &str, use_svg: bool, + cursor_type: &CursorType, ) -> Option { let mut loaded_cursor = None; - let cursor_shape = match &constants.recording_meta.inner { + let recorded_shape = match &constants.recording_meta.inner { RecordingMetaInner::Studio(studio) => match studio.as_ref() { StudioRecordingMeta::MultipleSegments { inner: @@ -289,6 +291,20 @@ impl CursorLayer { _ => None, }; + // An explicit family cross-maps the recorded shape into it (and + // stands in with its arrow for recordings that carry no shape info at + // all, e.g. Linux PNG-only captures), so SVG assets are the only + // possible source and `use_svg` no longer applies. + let (cursor_shape, use_svg) = match cursor_type.family() { + Some(family) => ( + Some( + recorded_shape.map_or_else(|| family.arrow(), |shape| shape.in_family(family)), + ), + true, + ), + None => (recorded_shape, use_svg), + }; + if let Some(cursor_shape) = cursor_shape && use_svg && let Some(info) = cursor_shape.resolve() @@ -315,7 +331,12 @@ impl CursorLayer { loaded_cursor } - fn preload_cursor_textures(&mut self, constants: &RenderVideoConstants, use_svg: bool) { + fn preload_cursor_textures( + &mut self, + constants: &RenderVideoConstants, + use_svg: bool, + cursor_type: &CursorType, + ) { let StudioRecordingMeta::MultipleSegments { inner, .. } = &constants.meta else { return; }; @@ -326,7 +347,8 @@ impl CursorLayer { for cursor_id in cursors.keys() { if !self.cursors.contains_key(cursor_id) - && let Some(texture) = Self::load_cursor_texture(constants, cursor_id, use_svg) + && let Some(texture) = + Self::load_cursor_texture(constants, cursor_id, use_svg, cursor_type) { self.cursors.insert(cursor_id.clone(), texture); } @@ -349,7 +371,7 @@ impl CursorLayer { } self.prev_is_svg_assets_enabled = Some(use_svg); - self.preload_cursor_textures(constants, use_svg); + self.preload_cursor_textures(constants, use_svg, cursor_type); self.cursor_assets_preloaded = true; } @@ -440,9 +462,13 @@ impl CursorLayer { let cursor_type = uniforms.project.cursor.cursor_type().clone(); + // The family a cursor id resolves to is baked into its texture, so a + // type change has to evict every cached sprite, not just the circle. if self.prev_cursor_type.as_ref() != Some(&cursor_type) { self.prev_cursor_type = Some(cursor_type.clone()); self.circle_cursor = None; + self.cursors.clear(); + self.cursor_assets_preloaded = false; } if self.prev_is_svg_assets_enabled != Some(uniforms.project.cursor.use_svg) { @@ -458,7 +484,11 @@ impl CursorLayer { self.circle_cursor.as_ref().unwrap() } else { if !self.cursor_assets_preloaded { - self.preload_cursor_textures(constants, uniforms.project.cursor.use_svg); + self.preload_cursor_textures( + constants, + uniforms.project.cursor.use_svg, + &cursor_type, + ); self.cursor_assets_preloaded = true; } if !self.cursors.contains_key(&interpolated_cursor.cursor_id) @@ -466,6 +496,7 @@ impl CursorLayer { constants, &interpolated_cursor.cursor_id, uniforms.project.cursor.use_svg, + &cursor_type, ) { self.cursors @@ -511,28 +542,118 @@ impl CursorLayer { }) }; - let hotspot = Coord::::new(size.coord * cursor_texture.hotspot); - - // Calculate position without hotspot first - let position = interpolated_cursor.position.to_frame_space( - &constants.options, - &uniforms.project, + let (position_size, cursor_opacity) = CursorPlacement { + constants, + uniforms, resolution_base, + zoom, + } + .map( + interpolated_cursor.position.coord, + size, + cursor_texture.hotspot, + cursor_opacity, + ); + + let cursor_grade = if uniforms.project.color_correction.grade_cursor { + uniforms.screen_color_grade + } else { + ColorGradeUniformParams::IDENTITY + }; + + let cursor_uniforms = CursorUniforms { + position_size, + output_size: [ + uniforms.output_size.0 as f32, + uniforms.output_size.1 as f32, + 0.0, + 0.0, + ], + screen_bounds: uniforms.display.target_bounds, + motion_vector_strength: [ + scaled_motion.x, + scaled_motion.y, + // Pure on/off gate: the amount is baked into the smear + // length (scaled_motion), never an opacity mix. + if cursor_strength > f32::EPSILON { + 1.0 + } else { + 0.0 + }, + cursor_opacity, + ], + rotation_params: [ + 0.0, + uniforms.project.cursor.base_rotation, + uniforms.cursor_x_axis_tilt_radians, + 0.0, + ], + color_adjust_a: cursor_grade.color_adjust_a, + color_adjust_b: cursor_grade.color_adjust_b, + grain_params: cursor_grade.grain_params, + }; + + constants.queue.write_buffer( + &self.statics.uniform_buffer, + 0, + bytemuck::cast_slice(&[cursor_uniforms]), + ); + + self.bind_group = Some( + self.statics + .create_bind_group(&constants.device, &cursor_texture.texture), + ); + } + + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { + if let Some(bind_group) = &self.bind_group { + pass.set_pipeline(&self.statics.render_pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..4, 0..1); + } + } +} + +/// The cursor sprite's output-rect chain: frame space minus hotspot -> zoomed +/// frame space -> split-screen pane remap -> text-takeover affine. The click +/// ripple has to land in exactly the same place as the sprite it sits under, +/// so both go through here. +pub(crate) struct CursorPlacement<'a> { + pub constants: &'a RenderVideoConstants, + pub uniforms: &'a ProjectUniforms, + pub resolution_base: XY, + pub zoom: &'a InterpolatedZoom, +} + +impl CursorPlacement<'_> { + pub(crate) fn map( + &self, + position_uv: XY, + size: Coord, + hotspot_frac: XY, + opacity: f32, + ) -> ([f32; 4], f32) { + let hotspot = Coord::::new(size.coord * hotspot_frac); + + let position = Coord::::new(position_uv).to_frame_space( + &self.constants.options, + &self.uniforms.project, + self.resolution_base, ) - hotspot; // Transform to zoomed space let zoomed_position = position.to_zoomed_frame_space( - &constants.options, - &uniforms.project, - resolution_base, - zoom, + &self.constants.options, + &self.uniforms.project, + self.resolution_base, + self.zoom, ); let zoomed_size = (position + size).to_zoomed_frame_space( - &constants.options, - &uniforms.project, - resolution_base, - zoom, + &self.constants.options, + &self.uniforms.project, + self.resolution_base, + self.zoom, ) - zoomed_position; // In split-screen the screen only occupies a half-rect, so remap the @@ -541,14 +662,15 @@ impl CursorLayer { // same factor. The cursor shader's screen_bounds clip already follows // uniforms.display.target_bounds (the morphing half), so a cursor that // lands outside the visible crop is confined automatically. - let position_size = match &uniforms.split { + let position_size = match &self.uniforms.split { Some(split) if split.factor > 0.001 => { - let screen_size = constants.options.screen_size; - let crop = ProjectUniforms::get_crop(&constants.options, &uniforms.project); + let screen_size = self.constants.options.screen_size; + let crop = + ProjectUniforms::get_crop(&self.constants.options, &self.uniforms.project); let display_size = ProjectUniforms::display_size( - &constants.options, - &uniforms.project, - resolution_base, + &self.constants.options, + &self.uniforms.project, + self.resolution_base, ); let scrop = split.screen.crop; @@ -559,8 +681,8 @@ impl CursorLayer { let target_h = starget[3] - starget[1]; let cursor_px = [ - cursor_uv.x as f32 * screen_size.x as f32, - cursor_uv.y as f32 * screen_size.y as f32, + position_uv.x as f32 * screen_size.x as f32, + position_uv.y as f32 * screen_size.y as f32, ]; let tip = [ starget[0] + (cursor_px[0] - scrop[0]) / crop_w * target_w, @@ -598,7 +720,7 @@ impl CursorLayer { // translate (the takeover target preserves the card's aspect), so the // cursor follows with the same affine map — and fades with the card // when a Fullscreen takeover hides it. - let (position_size, cursor_opacity) = match &uniforms.takeover { + let (position_size, opacity) = match &self.uniforms.takeover { Some(takeover) if takeover.t > 0.001 => { let from_w = (takeover.from[2] - takeover.from[0]).max(f32::EPSILON); let scale = (takeover.to[2] - takeover.to[0]) / from_w; @@ -616,68 +738,12 @@ impl CursorLayer { crate::lerp_f32(position_size[2], mapped[2], t), crate::lerp_f32(position_size[3], mapped[3], t), ], - cursor_opacity * takeover.overlay_fade, + opacity * takeover.overlay_fade, ) } - _ => (position_size, cursor_opacity), - }; - - let cursor_grade = if uniforms.project.color_correction.grade_cursor { - uniforms.screen_color_grade - } else { - ColorGradeUniformParams::IDENTITY + _ => (position_size, opacity), }; - - let cursor_uniforms = CursorUniforms { - position_size, - output_size: [ - uniforms.output_size.0 as f32, - uniforms.output_size.1 as f32, - 0.0, - 0.0, - ], - screen_bounds: uniforms.display.target_bounds, - motion_vector_strength: [ - scaled_motion.x, - scaled_motion.y, - // Pure on/off gate: the amount is baked into the smear - // length (scaled_motion), never an opacity mix. - if cursor_strength > f32::EPSILON { - 1.0 - } else { - 0.0 - }, - cursor_opacity, - ], - rotation_params: [ - 0.0, - uniforms.project.cursor.base_rotation, - uniforms.cursor_x_axis_tilt_radians, - 0.0, - ], - color_adjust_a: cursor_grade.color_adjust_a, - color_adjust_b: cursor_grade.color_adjust_b, - grain_params: cursor_grade.grain_params, - }; - - constants.queue.write_buffer( - &self.statics.uniform_buffer, - 0, - bytemuck::cast_slice(&[cursor_uniforms]), - ); - - self.bind_group = Some( - self.statics - .create_bind_group(&constants.device, &cursor_texture.texture), - ); - } - - pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { - if let Some(bind_group) = &self.bind_group { - pass.set_pipeline(&self.statics.render_pipeline); - pass.set_bind_group(0, bind_group, &[]); - pass.draw(0..4, 0..1); - } + (position_size, opacity) } } @@ -699,7 +765,7 @@ fn composite_cursor_layer(dst: &mut [f32; 4], src: [f32; 4]) { dst[3] = out_a; } -fn cursor_height_px( +pub(crate) fn cursor_height_px( source_screen_height: f32, crop_height: f32, display_frame_height: f32, diff --git a/crates/rendering/src/layers/mod.rs b/crates/rendering/src/layers/mod.rs index 5571e2fafbd..6de28b93912 100644 --- a/crates/rendering/src/layers/mod.rs +++ b/crates/rendering/src/layers/mod.rs @@ -4,6 +4,7 @@ mod blur; mod camera; mod camera3d; mod captions; +mod click_ripple; mod color_grade; mod cursor; mod display; @@ -70,6 +71,7 @@ pub use blur::*; pub use camera::*; pub use camera3d::*; pub use captions::*; +pub use click_ripple::*; pub use color_grade::*; pub use cursor::*; pub use display::*; diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index f9f752eb8e2..d7a1c1799d9 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -20,8 +20,8 @@ use frame_pipeline::{ use futures::future::OptionFuture; use layers::{ Background, BackgroundLayer, BlurLayer, Camera3DBlurKind, Camera3DLayer, CameraLayer, - CaptionsLayer, ColorGradeLayer, CursorLayer, DisplayLayer, FrameLayer, KeyboardLayer, - MaskLayer, NotchLayer, NotchUniforms, TextLayer, + CaptionsLayer, ClickRippleLayer, ColorGradeLayer, CursorLayer, DisplayLayer, FrameLayer, + KeyboardLayer, MaskLayer, NotchLayer, NotchUniforms, TextLayer, }; use specta::Type; use spring_mass_damper::SpringMassDamperSimulationConfig; @@ -2355,6 +2355,14 @@ fn fit_crop_to_target( [x0, y0, x0 + w, y0 + h] } +/// One in-flight click ripple: where the drawn cursor was when the button +/// went down, and how far through its animation it is. +#[derive(Clone, Debug)] +pub struct ClickRipple { + pub position: Coord, + pub progress: f32, +} + #[derive(Clone, Debug)] pub struct ProjectUniforms { pub output_size: (u32, u32), @@ -2380,6 +2388,7 @@ pub struct ProjectUniforms { display_outer_bounds: [f32; 4], interpolated_cursor: Option, pub prev_cursor: Option, + pub click_ripples: Vec, pub project: ProjectConfiguration, pub zoom: InterpolatedZoom, pub scene: InterpolatedScene, @@ -2401,6 +2410,48 @@ pub struct ProjectUniforms { pub camera3d_zoom: Option, } +/// Ripples for every mouse-down still inside its animation window, newest +/// last. The centre comes from the smoothed cursor path at the click's own +/// timestamp, so the ring sits under where the cursor was DRAWN rather than +/// where the raw sample was. +fn collect_click_ripples( + project: &ProjectConfiguration, + cursor_events: &CursorEvents, + now_ms: f64, + cursor_interp_fn: &dyn Fn(f32) -> Option, +) -> Vec { + let ripple = &project.cursor.ripple; + if project.cursor.hide || !ripple.enabled { + return Vec::new(); + } + + let duration_ms = ripple.duration_clamped() as f64 * 1000.0; + let mut ripples: Vec = cursor_events + .clicks + .iter() + .filter(|click| click.down) + .filter_map(|click| { + let age_ms = now_ms - click.time_ms; + if age_ms < 0.0 || age_ms >= duration_ms { + return None; + } + + let position = cursor_interp_fn((click.time_ms / 1000.0) as f32)?.position; + + Some(ClickRipple { + position, + progress: (age_ms / duration_ms) as f32, + }) + }) + .collect(); + + if ripples.len() > layers::MAX_CLICK_RIPPLES { + ripples.drain(..ripples.len() - layers::MAX_CLICK_RIPPLES); + } + + ripples +} + #[derive(Debug, Clone)] pub struct Zoom { pub amount: f64, @@ -3407,7 +3458,7 @@ impl ProjectUniforms { frame_number: u32, fps: u32, resolution_base: XY, - _cursor_events: &CursorEvents, + cursor_events: &CursorEvents, segment_frames: &DecodedSegmentFrames, total_duration: f64, zoom_timeline: &ZoomTransformTimeline, @@ -3462,6 +3513,12 @@ impl ProjectUniforms { let interpolated_cursor = cursor_interp_fn(cursor_time_for_interp); let prev_interpolated_cursor = cursor_interp_fn(prev_cursor_time_for_interp); + let click_ripples = collect_click_ripples( + project, + cursor_events, + current_recording_time as f64 * 1000.0, + cursor_interp_fn, + ); let lookback_t = (cursor_time_for_interp - 0.4).max(0.0); let past_cursor_for_tilt = cursor_interp_fn(lookback_t); @@ -4429,6 +4486,7 @@ impl ProjectUniforms { frame_number, recording_time: current_recording_time as f64, prev_cursor: prev_interpolated_cursor, + click_ripples, display_parent_motion_px: display_motion_parent, motion_blur_amount: cursor_motion_blur, masks, @@ -5718,6 +5776,7 @@ pub struct RendererLayers { frame: FrameLayer, display: DisplayLayer, notch: NotchLayer, + click_ripple: ClickRippleLayer, cursor: CursorLayer, camera: CameraLayer, camera_only: CameraLayer, @@ -5756,6 +5815,7 @@ impl RendererLayers { shared_composite_pipeline.clone(), prefer_cpu_conversion, ), + click_ripple: ClickRippleLayer::new(device), cursor: CursorLayer::new(device), camera: CameraLayer::new_with_all_shared_pipelines( device, @@ -5928,6 +5988,13 @@ impl RendererLayers { ); } + self.click_ripple.prepare( + uniforms, + uniforms.resolution_base, + &uniforms.zoom, + constants, + ); + self.cursor.prepare( segment_frames, uniforms.resolution_base, @@ -6083,6 +6150,12 @@ impl RendererLayers { timings.display_prepare_duration = start.elapsed(); let start = Instant::now(); + self.click_ripple.prepare( + uniforms, + uniforms.resolution_base, + &uniforms.zoom, + constants, + ); self.cursor.prepare( segment_frames, uniforms.resolution_base, @@ -6301,6 +6374,7 @@ impl RendererLayers { if should_render_cursor { let mut pass = render_pass!(content_view!(), wgpu::LoadOp::Load); + self.click_ripple.render(&mut pass); self.cursor.render(&mut pass); } diff --git a/crates/rendering/src/shaders/click-ripple.wgsl b/crates/rendering/src/shaders/click-ripple.wgsl new file mode 100644 index 00000000000..99af58b8c01 --- /dev/null +++ b/crates/rendering/src/shaders/click-ripple.wgsl @@ -0,0 +1,75 @@ +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +struct Uniforms { + position_size: vec4, + output_size: vec4, + screen_bounds: vec4, + // (r, g, b, strength). + color: vec4, + // (progress, opacity, quad half-extent in ring radii, unused). + params: vec4, +}; + +@group(0) @binding(0) +var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var corners = array, 4>( + vec2(0.0, 0.0), + vec2(0.0, 1.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0) + ); + + let local_uv = corners[vertex_index]; + let pos = vec2(local_uv.x, -local_uv.y); + + var adjusted_pos = uniforms.position_size.xy; + adjusted_pos.y = uniforms.output_size.y - adjusted_pos.y; + + let final_pos = ((pos * uniforms.position_size.zw) + adjusted_pos) + / uniforms.output_size.xy * 2.0 - 1.0; + + var output: VertexOutput; + output.position = vec4(final_pos, 0.0, 1.0); + output.uv = local_uv; + return output; +} + +// Same feathered display-card clip the cursor sprite uses, so a ripple whose +// click landed in a cropped-away region slides off the card edge instead of +// floating over the background. +fn screen_bounds_mask(frag_pos: vec2) -> f32 { + let b = uniforms.screen_bounds; + let inside = min( + min(frag_pos.x - b.x, b.z - frag_pos.x), + min(frag_pos.y - b.y, b.w - frag_pos.y), + ); + return clamp(inside + 0.5, 0.0, 1.0); +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let t = clamp(uniforms.params.x, 0.0, 1.0); + let inv = 1.0 - t; + + // Distances are measured in ring radii, so R cancels out of the whole + // profile below. + let d = length(input.uv - vec2(0.5)) * 2.0 * uniforms.params.z; + + let r = 1.0 - inv * inv * inv; + let w = 0.10 + 0.06 * t; + + let ring = 1.0 - smoothstep(0.0, w, abs(d - r)); + let fill = (1.0 - smoothstep(r - w, r, d)) * 0.30; + + var alpha = uniforms.color.a * pow(inv, 1.5) * (ring + fill); + alpha *= uniforms.params.y * screen_bounds_mask(input.position.xy); + alpha = clamp(alpha, 0.0, 1.0); + + return vec4(uniforms.color.rgb * alpha, alpha); +} From 7bea568a6c234dc21e66851a9c2d3f3b532ab003 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:37:51 +0100 Subject: [PATCH 04/10] feat: render preview images at a chosen timeline position --- crates/rendering/src/main.rs | 37 +++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/rendering/src/main.rs b/crates/rendering/src/main.rs index 0fef9a0943f..abcf7720378 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -31,6 +31,14 @@ struct Args { /// Output resolution height (defaults to project resolution) #[arg(long)] height: Option, + + /// Timeline position to render, in seconds + #[arg(long, default_value_t = 0.0)] + time: f64, + + /// Frame rate the timeline is sampled at; `--time` snaps to this grid + #[arg(long, default_value_t = 30)] + fps: u32, } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] @@ -167,9 +175,16 @@ async fn main() -> Result<()> { std::fs::create_dir_all(parent).context("Failed to create output directory")?; } + let fps = args.fps.max(1); + let target_frame = (args.time.max(0.0) * fps as f64).round() as u32; + println!( - "Rendering first frame at {}x{}", - output_size.0, output_size.1 + "Rendering frame {} (t={:.3}s @ {}fps) at {}x{}", + target_frame, + target_frame as f64 / fps as f64, + fps, + output_size.0, + output_size.1 ); println!("Output: {}", output_path.display()); @@ -185,24 +200,28 @@ async fn main() -> Result<()> { &recording_meta.clone(), &studio_meta, render_segments, - 1, // Only render 1 frame + fps, XY::new(output_size.0, output_size.1), &recordings, ) .await }); - // Wait for the first frame - let (frame, frame_number) = rx - .recv() - .await - .ok_or_else(|| anyhow::anyhow!("No frame received"))?; + let mut received = None; + while let Some((frame, frame_number)) = rx.recv().await { + if frame_number >= target_frame { + received = Some((frame, frame_number)); + break; + } + } + + let (frame, frame_number) = + received.ok_or_else(|| anyhow::anyhow!("No frame received at t={}s", args.time))?; println!( "Received frame {} ({}x{})", frame_number, frame.width, frame.height ); - // Cancel the render task since we only want the first frame render_task.abort(); // Save the frame in the requested format From be6a59f9e7412d27bd377be9fefefe2dc3806251 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:37:51 +0100 Subject: [PATCH 05/10] feat: add cursor style and ripple controls to the desktop editor --- .../src/routes/editor/ConfigSidebar.tsx | 76 ++----- .../src/routes/editor/CursorStylePicker.tsx | 213 ++++++++++++++++++ 2 files changed, 234 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/routes/editor/CursorStylePicker.tsx diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 59f0fb9c939..037114aa937 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -70,7 +70,6 @@ import { type CaptionTrackSegment, type ClipOffsets, type CursorAnimationStyle, - type CursorType, commands, type KeyboardTrackSegment, type NotchConfiguration, @@ -121,6 +120,11 @@ import { } from "./audio"; import { BrandColorsDropdown } from "./BrandColorsDropdown"; import { ColorCorrectionSection } from "./ColorCorrectionSection"; +import { + CursorRippleSection, + CursorStylePicker, + isExplicitCursorFamily, +} from "./CursorStylePicker"; import { syncCaptionWordsWithText } from "./captions"; import { type ClipTransition, clipSourceTimeAt } from "./clip-transitions"; import { getColorPreviewBorderColor, hexToRgb, RgbInput } from "./color-utils"; @@ -428,19 +432,6 @@ type CursorPresetValues = { const DEFAULT_MOTION_BLUR = 1.0; -const CURSOR_TYPE_OPTIONS = [ - { - value: "auto" as CursorType, - label: "Auto", - description: "Uses the actual cursor from your recording.", - }, - { - value: "circle" as CursorType, - label: "Circle", - description: "A touch-style circle cursor like mobile simulators.", - }, -]; - const CURSOR_ANIMATION_STYLE_OPTIONS = [ { value: "slow", @@ -841,35 +832,7 @@ export function ConfigSidebar() { } /> - }> - - setProject("cursor", "type", value as CursorType) - } - > - {CURSOR_TYPE_OPTIONS.map((option) => ( - - - - -
- - {option.label} - - - {option.description} - -
-
-
- ))} -
-
+ }> `${Math.round(value * 100)}%`} /> + } @@ -1000,18 +964,20 @@ export function ConfigSidebar() { - } - value={ - { - setProject("cursor", "useSvg", value); - }} - /> - } - /> + + } + value={ + { + setProject("cursor", "useSvg", value); + }} + /> + } + /> +
{/* }> diff --git a/apps/desktop/src/routes/editor/CursorStylePicker.tsx b/apps/desktop/src/routes/editor/CursorStylePicker.tsx new file mode 100644 index 00000000000..90fef4d8b4e --- /dev/null +++ b/apps/desktop/src/routes/editor/CursorStylePicker.tsx @@ -0,0 +1,213 @@ +import { Collapsible as KCollapsible } from "@kobalte/core/collapsible"; +import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group"; +import { type as ostype } from "@tauri-apps/plugin-os"; +import { createMemo, For, Show } from "solid-js"; +import { Toggle } from "~/components/Toggle"; +import Tooltip from "~/components/Tooltip"; +import type { CursorRippleConfig, CursorType } from "~/utils/tauri"; +import IconLucideMousePointerClick from "~icons/lucide/mouse-pointer-click"; +import macArrow from "../../../../../crates/cursor-info/assets/mac/arrow.svg?raw"; +import tahoeArrow from "../../../../../crates/cursor-info/assets/mac/tahoe/default.svg?raw"; +import windowsArrow from "../../../../../crates/cursor-info/assets/windows/arrow.svg?raw"; +import { RgbInput } from "./color-utils"; +import { type TransformedMeta, useEditorContext } from "./context"; +import { Field, Slider } from "./ui"; + +export type CursorFamily = "macos" | "tahoe" | "windows"; +type CursorStyle = CursorFamily | "circle"; + +// One arrow per family: the arrow is what makes a cursor family recognisable +// at a glance, and the tile only has to say "this one", not exhibit the set. +const CURSOR_FAMILIES = { + macos: { label: "macOS", arrow: macArrow }, + tahoe: { label: "macOS Tahoe", arrow: tahoeArrow }, + windows: { label: "Windows", arrow: windowsArrow }, +} satisfies Record; + +export const DEFAULT_CURSOR_RIPPLE: CursorRippleConfig = { + enabled: false, + color: [71, 133, 255], + strength: 0.7, + size: 1, + duration: 0.6, +}; + +export function cursorFamilyFromShape( + shape: string | null | undefined, +): CursorFamily | undefined { + if (!shape) return undefined; + const [namespace, name] = shape.split("|"); + if (namespace === "Windows") return "windows"; + if (namespace === "MacOS") + return name?.startsWith("Tahoe") ? "tahoe" : "macos"; + return undefined; +} + +export function recordedCursorFamily( + meta: TransformedMeta, +): CursorFamily | undefined { + if (meta.type !== "multiple") return undefined; + for (const cursor of Object.values(meta.cursors)) { + if (typeof cursor === "string") continue; + const family = cursorFamilyFromShape(cursor.shape); + if (family) return family; + } + return undefined; +} + +export function isExplicitCursorFamily(type: CursorType): type is CursorFamily { + return type === "macos" || type === "tahoe" || type === "windows"; +} + +function hostCursorFamily(): CursorFamily { + return ostype() === "windows" ? "windows" : "macos"; +} + +function cursorStyleOrder(): CursorStyle[] { + return ostype() === "windows" + ? ["windows", "macos", "tahoe", "circle"] + : ["macos", "tahoe", "windows", "circle"]; +} + +function CursorArrow(props: { svg: string }) { + // The assets carry their own black-on-white edge and a soft drop shadow, + // so they read on the plain tile in both themes, the same way a real + // cursor reads over a light or dark window. + return ( +
+ ); +} + +// The renderer's touch circle (`create_circle_cursor`): a translucent disc +// with a dark outer ring, a light inner ring and a faint shadow. +function CircleCursor() { + return ( +
+ ); +} + +function CursorStyleCard(props: { style: CursorStyle; recorded: boolean }) { + const label = () => + props.style === "circle" ? "Circle" : CURSOR_FAMILIES[props.style].label; + + const tile = () => ( +
+ } + > + {(family) => } + +
+ ); + + return ( + + + + + + {tile()} + + + + {label()} + + + + ); +} + +export function CursorStylePicker() { + const { project, setProject, meta } = useEditorContext(); + + const recorded = createMemo(() => recordedCursorFamily(meta())); + + // `auto` draws the recording's own family (or, without shape info, this + // host's), so that is the tile that reads as selected. Clicking always + // writes an explicit family; `auto` is never written back. + const selected = createMemo(() => { + const type = project.cursor.type; + if (type === "circle" || isExplicitCursorFamily(type)) return type; + return recorded() ?? hostCursorFamily(); + }); + + return ( + }> + setProject("cursor", "type", value as CursorType)} + > + + {(style) => ( + + )} + + + + ); +} + +export function CursorRippleSection() { + const { project, setProject } = useEditorContext(); + + const ripple = () => project.cursor.ripple ?? DEFAULT_CURSOR_RIPPLE; + const updateRipple = (patch: Partial) => + setProject("cursor", "ripple", { ...ripple(), ...patch }); + + return ( + + } + value={ + updateRipple({ enabled: value })} + /> + } + /> + +
+ + updateRipple({ color })} + /> + + + updateRipple({ strength: v[0] / 100 })} + minValue={0} + maxValue={100} + step={1} + formatTooltip={(v) => `${Math.round(v)}%`} + /> + + + updateRipple({ size: v[0] / 100 })} + minValue={25} + maxValue={300} + step={1} + formatTooltip={(v) => `${Math.round(v)}%`} + /> + + + updateRipple({ duration: v[0] })} + minValue={0.2} + maxValue={1.5} + step={0.05} + formatTooltip={(v) => `${v.toFixed(2)}s`} + /> + +
+
+
+ ); +} From e6550139e08765cc13cd794e1a831e9d8f59ff4a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:39:30 +0100 Subject: [PATCH 06/10] feat: add cursor style and ripple controls to the GPUI editor --- apps/desktop-gpui/Cargo.lock | 2 + apps/desktop-gpui/Cargo.toml | 6 + .../assets/icons/mouse-pointer-click.svg | 1 + apps/desktop-gpui/src/assets.rs | 4 + apps/desktop-gpui/src/editor_sidebar.rs | 34 +- .../desktop-gpui/src/editor_sidebar/cursor.rs | 474 ++++++++++++++++++ apps/desktop-gpui/src/editor_tabs.rs | 288 +++++------ apps/desktop-gpui/src/editor_window.rs | 33 +- 8 files changed, 678 insertions(+), 164 deletions(-) create mode 100644 apps/desktop-gpui/assets/icons/mouse-pointer-click.svg create mode 100644 apps/desktop-gpui/src/editor_sidebar/cursor.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 9dda7ec44be..c6ed95126d9 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1525,6 +1525,7 @@ dependencies = [ "base64 0.22.1", "cap-camera", "cap-camera-effects", + "cap-cursor-info", "cap-editor", "cap-enc-ffmpeg", "cap-export", @@ -1558,6 +1559,7 @@ dependencies = [ "parakeet-rs", "raw-window-handle", "reqwest 0.12.28", + "resvg 0.45.1", "rfd", "scap-targets", "semver", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index b634366052a..890ee068404 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -56,6 +56,12 @@ cap-enc-ffmpeg = { path = "../../crates/enc-ffmpeg" } cap-timestamp = { path = "../../crates/timestamp" } cap-utils = { path = "../../crates/utils" } cap-project = { path = "../../crates/project" } +# The cursor style picker draws the same SVG assets the renderer composites. +# gpui's `svg()` keeps only a glyph's alpha, so the two-tone cursor art has to +# be rasterised instead: `resvg` at the pin cap-rendering already builds, into +# a `RenderImage` (`src/editor_sidebar/cursor.rs`). +cap-cursor-info = { path = "../../crates/cursor-info" } +resvg = "0.45" # The editor stack. `cap-rendering` is not a new subtree -- cap-recording # already depends on it, so wgpu 25 and its vendored wgpu-hal have been built # by this workspace since the recording unit; naming it here only makes the diff --git a/apps/desktop-gpui/assets/icons/mouse-pointer-click.svg b/apps/desktop-gpui/assets/icons/mouse-pointer-click.svg new file mode 100644 index 00000000000..0d67529c872 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/mouse-pointer-click.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 03be91d21be..e42ea95f33c 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -168,6 +168,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "maximize.svg", "moon.svg", "mouse-pointer-2.svg", + "mouse-pointer-click.svg", "move.svg", "move-right.svg", "palette.svg", @@ -324,6 +325,9 @@ mod tests { // The five later tabs, the colour-grade section and the eight segment // panels each name their own glyphs. include_str!("editor_tabs.rs"), + // The cursor tab's style picker and ripple section live one directory + // down, and the ripple field names its own glyph. + include_str!("editor_sidebar/cursor.rs"), include_str!("editor_color.rs"), include_str!("editor_panels.rs"), // The crop dialog's ratio / Full / Reset glyphs and the chevron diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 057f37a4951..a17b89f2caf 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -35,7 +35,7 @@ //! re-encode (`src-tauri/src/recording.rs:181-208, 437-472`). use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, path::{Path, PathBuf}, sync::Arc, time::Instant, @@ -63,6 +63,7 @@ use crate::{ }; mod animated_gradient; +mod cursor; // --------------------------------------------------------------------------- // The catalogue: every constant the background section reads @@ -725,6 +726,9 @@ pub enum ColorTarget { GradientTo, AnimatedGradientStop(usize), BorderColor, + /// The click ripple's ring colour, the one `[u8; 3]` target outside the + /// background tab. + CursorRipple, CaptionColor, CaptionBackground, CaptionHighlight, @@ -744,6 +748,7 @@ impl ColorTarget { | Self::GradientTo | Self::AnimatedGradientStop(_) | Self::BorderColor + | Self::CursorRipple ) } } @@ -797,6 +802,16 @@ pub struct SidebarState { /// `KCollapsible open={!project.cursor.raw}` physics panel. pub camera_shadow_open: CollapsibleState, pub cursor_physics_open: CollapsibleState, + /// The click-ripple settings, revealed by their own toggle. + pub cursor_ripple_open: CollapsibleState, + /// The style picker's rasterised cursor art, keyed by shape and the device + /// pixel box it was drawn for. A `BTreeMap` because `CursorShape` is `Ord` + /// but not `Hash`. + cursor_previews: + std::cell::RefCell>>, + /// The device scale those were rasterised at, sampled once a frame from + /// `render` -- the only place in the sidebar's chain with a `&Window`. + cursor_scale: f32, /// The 3D panel's three `Camera3DSection`s and the zoom panel's helper. pub panel_sections: std::cell::RefCell>>, @@ -873,6 +888,9 @@ impl SidebarState { grade_previews: std::cell::RefCell::new(HashMap::new()), camera_shadow_open: CollapsibleState::new(false), cursor_physics_open: CollapsibleState::new(!config.cursor.raw), + cursor_ripple_open: CollapsibleState::new(config.cursor.ripple.enabled), + cursor_previews: std::cell::RefCell::new(BTreeMap::new()), + cursor_scale: 2., panel_sections: std::cell::RefCell::new(HashMap::new()), noise: std::cell::RefCell::new(None), menu: None, @@ -1628,6 +1646,7 @@ impl EditorWindow { .as_ref() .map_or(UI_BORDER_FALLBACK.color, |border| border.color), ), + ColorTarget::CursorRipple => Some(self.project.cursor.ripple.color), _ => self.hex_string_for(target).and_then(|hex| { hex_to_rgb(&hex).map(|rgba| [rgba[0] as u16, rgba[1] as u16, rgba[2] as u16]) }), @@ -1664,6 +1683,17 @@ impl EditorWindow { if target.is_hex_string() { return self.set_hex_color(target, color, window, cx); } + // The one `[u8; 3]` target that does not live under `background`, so + // it takes the general fan-out rather than `edit_background`. + if target == ColorTarget::CursorRipple { + return self.edit_project("cursor-ripple-color", window, cx, move |project| { + if project.cursor.ripple.color == color { + return false; + } + project.cursor.ripple.color = color; + true + }); + } self.edit_background( "color", |project| { @@ -3864,6 +3894,8 @@ pub(crate) fn format_slider_value(value: f32, unit: &str) -> String { "int" => format!("{}", value.round() as i32), "x100%" => format!("{}%", (value * 100.).round() as i32), "pct" => format!("{:.1}%", value * 100.), + // The ripple duration's `${v.toFixed(2)}s`. + "secs" => format!("{value:.2}s"), unit => format!("{value:.1}{unit}"), } } diff --git a/apps/desktop-gpui/src/editor_sidebar/cursor.rs b/apps/desktop-gpui/src/editor_sidebar/cursor.rs new file mode 100644 index 00000000000..6bbcb0c8f2a --- /dev/null +++ b/apps/desktop-gpui/src/editor_sidebar/cursor.rs @@ -0,0 +1,474 @@ +//! The Cursor tab's style picker and its click-ripple section. +//! +//! The picker is one row of four tiles, each showing a family's arrow drawn +//! from the **real** cursor art in `crates/cursor-info/assets` -- the same +//! SVGs the renderer composites -- so the choice is made by looking at the +//! cursor rather than by reading the word "Windows". The arrow alone is what +//! makes a family recognisable, so the tile shows nothing else. `svg()` keeps +//! only a glyph's alpha and tints it with the element's text colour, which +//! would flatten a two-tone cursor into a silhouette, so each arrow is +//! rasterised with `resvg` into a [`gpui::RenderImage`] and cached per +//! (shape, device-pixel box). +//! +//! The tiles are plain theme surfaces: the assets carry their own +//! black-on-white edge and a soft drop shadow, so they read on a light or a +//! dark tile the way a real cursor reads over a light or dark window. +//! +//! The fourth tile is `Circle`, whose art has no asset: it is the renderer's +//! own touch circle (`crates/rendering/src/layers/cursor.rs` +//! `create_circle_cursor`) restated with gpui primitives. + +use cap_cursor_info::{CursorFamily, CursorShape}; +use cap_project::CursorType; + +use super::*; +use crate::editor_tabs::CursorSlider; + +const CARD_GAP: f32 = 8.; +const TILE_HEIGHT: f32 = 60.; +const TILE_RADIUS: f32 = 10.; +/// The arrow's box. Square, and every arrow asset is taller than it is wide, +/// so the fit lands on the height and each family keeps its own width. +const ARROW_BOX: f32 = 34.; +const CIRCLE_DISC: f32 = 28.; +const CARD_GROUP: &str = "cursor-style-card"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CursorCard { + Family(CursorFamily), + Circle, +} + +impl CursorCard { + fn label(self) -> &'static str { + match self { + Self::Family(CursorFamily::MacOS) => "macOS", + Self::Family(CursorFamily::MacOSTahoe) => "macOS Tahoe", + Self::Family(CursorFamily::Windows) => "Windows", + Self::Circle => "Circle", + } + } + + fn key(self) -> &'static str { + match self { + Self::Family(CursorFamily::MacOS) => "macos", + Self::Family(CursorFamily::MacOSTahoe) => "tahoe", + Self::Family(CursorFamily::Windows) => "windows", + Self::Circle => "circle", + } + } + + /// What clicking the card writes. Always explicit -- the picker never + /// writes `Auto` back, because a card is only ever shown selected on the + /// strength of a family it can name. + fn cursor_type(self) -> CursorType { + match self { + Self::Family(CursorFamily::MacOS) => CursorType::MacOS, + Self::Family(CursorFamily::MacOSTahoe) => CursorType::MacOSTahoe, + Self::Family(CursorFamily::Windows) => CursorType::Windows, + Self::Circle => CursorType::Circle, + } + } +} + +/// Host order: the platform's own cursors first, then the other two, then the +/// styled circle. +fn cursor_cards() -> [CursorCard; 4] { + if cfg!(target_os = "windows") { + [ + CursorCard::Family(CursorFamily::Windows), + CursorCard::Family(CursorFamily::MacOS), + CursorCard::Family(CursorFamily::MacOSTahoe), + CursorCard::Circle, + ] + } else { + [ + CursorCard::Family(CursorFamily::MacOS), + CursorCard::Family(CursorFamily::MacOSTahoe), + CursorCard::Family(CursorFamily::Windows), + CursorCard::Circle, + ] + } +} + +/// Which card reads as selected: the explicit type when there is one, and +/// otherwise the family the recording was made with -- or, failing that, this +/// host's -- because that is what `Auto` will actually draw. +fn selected_card(cursor_type: &CursorType, recorded: Option) -> CursorCard { + if *cursor_type == CursorType::Circle { + return CursorCard::Circle; + } + match cursor_type.family() { + Some(family) => CursorCard::Family(family), + None => CursorCard::Family(recorded.unwrap_or(host_cursor_family())), + } +} + +fn host_cursor_family() -> CursorFamily { + if cfg!(target_os = "windows") { + CursorFamily::Windows + } else { + CursorFamily::MacOS + } +} + +fn white(alpha: f32) -> Hsla { + gpui::hsla(0., 0., 1., alpha) +} + +fn black(alpha: f32) -> Hsla { + gpui::hsla(0., 0., 0., alpha) +} + +/// One cursor shape, rasterised to fit `width` x `height` device pixels. +fn rasterize_cursor(shape: CursorShape, width: u32, height: u32) -> Option> { + let raw = shape.resolve()?.raw; + let tree = resvg::usvg::Tree::from_str(raw, &resvg::usvg::Options::default()).ok()?; + let size = tree.size(); + let scale = (width as f32 / size.width()).min(height as f32 / size.height()); + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?; + let transform = resvg::tiny_skia::Transform::from_translate( + (width as f32 - size.width() * scale) / 2., + (height as f32 - size.height() * scale) / 2., + ) + .pre_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + let mut buffer = image::RgbaImage::from_raw(width, height, pixmap.take())?; + // tiny-skia hands back premultiplied RGBA and gpui's atlas takes straight + // BGRA -- the same conversion `gpui::SvgRenderer::render_single_frame` does + // on its own pixmap. Skipping it darkens every antialiased edge. + for pixel in buffer.chunks_exact_mut(4) { + gpui::swap_rgba_pa_to_bgra(pixel); + } + Some(Arc::new(RenderImage::new(smallvec::smallvec![ + image::Frame::new(buffer) + ]))) +} + +/// The touch circle: a translucent disc with a dark outer ring, a light inner +/// ring and a faint shadow, as `create_circle_cursor` draws it. +fn circle_art() -> AnyElement { + div() + .size(px(CIRCLE_DISC)) + .rounded_full() + .bg(white(0.15)) + .border_1() + .border_color(black(0.38)) + .shadow(vec![gpui::BoxShadow { + color: black(0.16), + offset: gpui::point(px(0.), px(0.)), + blur_radius: px(5.), + spread_radius: px(0.), + inset: false, + }]) + .child( + div() + .size_full() + .rounded_full() + .border_1() + .border_color(white(0.42)), + ) + .into_any_element() +} + +impl EditorWindow { + /// The ripple colour's hex field, and the device scale the previews are + /// rasterised for. Both need a `&mut Window`, which the sidebar's render + /// chain does not carry, so they are settled once a frame from `render`. + pub(crate) fn prepare_cursor_fields(&mut self, window: &mut Window, cx: &mut Context) { + if self.sidebar.tab != SidebarTab::Cursor { + return; + } + self.sidebar.cursor_scale = window.scale_factor(); + self.ensure_hex_input(ColorTarget::CursorRipple, window, cx); + } + + fn selected_cursor_card(&self) -> CursorCard { + selected_card( + self.project.cursor.cursor_type(), + self.recorded_cursor_family, + ) + } + + fn cursor_preview(&self, shape: CursorShape, size: f32) -> Option> { + let scale = self.sidebar.cursor_scale.max(1.); + let side = (size * scale).round() as u32; + let key = (shape, side, side); + if let Some(image) = self.sidebar.cursor_previews.borrow().get(&key) { + return Some(image.clone()); + } + let image = rasterize_cursor(shape, side, side)?; + self.sidebar + .cursor_previews + .borrow_mut() + .insert(key, image.clone()); + Some(image) + } + + fn cursor_art(&self, shape: CursorShape, size: f32) -> AnyElement { + div() + .size(px(size)) + .flex() + .items_center() + .justify_center() + .children( + self.cursor_preview(shape, size) + .map(|image| img(image).size(px(size))), + ) + .into_any_element() + } + + /// The tile: the family's arrow (or the touch circle) centred on a plain + /// surface. `RadioCards`' grammar for the states -- `border-gray-3 + /// bg-gray-2`, `hover:border-gray-5`, and `border-blue-8 bg-blue-3/40` + /// plus a 1px ring (so a 2px edge) when checked. + fn render_cursor_tile(&self, card: CursorCard, selected: bool, recorded: bool) -> AnyElement { + let theme = self.theme; + let art = match card { + CursorCard::Family(family) => self.cursor_art(family.arrow(), ARROW_BOX), + CursorCard::Circle => circle_art(), + }; + + div() + .id(SharedString::from(format!("cursor-tile-{}", card.key()))) + .w_full() + .h(px(TILE_HEIGHT)) + .rounded(px(TILE_RADIUS)) + .flex() + .items_center() + .justify_center() + .map(|this| { + if selected { + this.border_2() + .border_color(Hsla::from(theme.blue_8)) + .bg(with_alpha(theme.blue_3, 0.4)) + } else { + this.border_1() + .border_color(Hsla::from(theme.gray_3)) + .bg(Hsla::from(theme.gray_2)) + .group_hover(CARD_GROUP, |this| { + this.border_color(Hsla::from(theme.gray_5)) + }) + } + }) + .when(recorded, |this| { + this.tooltip(move |_window, cx| { + ui::Tooltip::new(&theme, "Recorded with this cursor").view(cx) + }) + }) + .child(art) + .into_any_element() + } + + fn render_cursor_card( + &self, + card: CursorCard, + selected: bool, + recorded: Option, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let cursor_type = card.cursor_type(); + let is_recorded = matches!(card, CursorCard::Family(family) if recorded == Some(family)); + + div() + .id(SharedString::from(format!("cursor-card-{}", card.key()))) + .group(CARD_GROUP) + .flex_1() + .min_w_0() + .flex() + .flex_col() + .items_center() + .gap(px(6.)) + .cursor_pointer() + .child(self.render_cursor_tile(card, selected, is_recorded)) + .child( + div() + .max_w_full() + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .text_size(px(11.)) + .line_height(px(11.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(if selected { + theme.gray_12 + } else { + theme.gray_11 + })) + .when(!selected, |this| { + this.group_hover(CARD_GROUP, |this| { + this.text_color(Hsla::from(theme.gray_12)) + }) + }) + .child(card.label()), + ) + .on_click(cx.listener(move |this, _, window, cx| { + // `CursorType` is not `Copy`, and the listener is an `Fn`. + let cursor_type = cursor_type.clone(); + this.edit_project("cursor-type", window, cx, move |project| { + if *project.cursor.cursor_type() == cursor_type { + return false; + } + project.cursor.set_cursor_type(cursor_type); + true + }); + })) + .into_any_element() + } + + /// `grid grid-cols-4 gap-2`: one row, the four cards sharing the width. + pub(crate) fn render_cursor_style_picker(&self, cx: &mut Context) -> AnyElement { + let selected = self.selected_cursor_card(); + let recorded = self.recorded_cursor_family; + + div() + .flex() + .flex_row() + .gap(px(CARD_GAP)) + .children( + cursor_cards() + .into_iter() + .map(|card| self.render_cursor_card(card, selected == card, recorded, cx)), + ) + .into_any_element() + } + + /// "Click Ripple" and, once it is on, the ring's colour and its three + /// shape sliders. + pub(crate) fn render_cursor_ripple(&self, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let ripple = &self.project.cursor.ripple; + let enabled = ripple.enabled; + let color = ripple.color; + + div() + .flex() + .flex_col() + .child( + ui::Field::plain(&theme, "Click Ripple") + .icon("icons/mouse-pointer-click.svg") + .value( + ui::Toggle::plain(&theme, "cursor-ripple", enabled) + .on_click(cx.listener(move |this, _, window, cx| { + let next = !this.project.cursor.ripple.enabled; + this.sidebar.cursor_ripple_open.set_open(next); + this.animate_collapsibles(window, cx); + this.edit_project("cursor-ripple", window, cx, move |project| { + project.cursor.ripple.enabled = next; + true + }); + })) + .into_any_element(), + ), + ) + .child(collapsible( + &self.sidebar.cursor_ripple_open, + div() + .flex() + .flex_col() + .gap(px(16.)) + .pt(px(16.)) + .pb(px(24.)) + .child( + ui::Subfield::plain(&theme, "Color").child(self.render_rgb_input( + "cursor-ripple-color", + ColorTarget::CursorRipple, + color, + cx, + )), + ) + .child(ui::Field::plain(&theme, "Strength").child(self.slider( + SliderKey::Cursor(CursorSlider::RippleStrength), + "%", + cx, + ))) + .child(ui::Field::plain(&theme, "Size").child(self.slider( + SliderKey::Cursor(CursorSlider::RippleSize), + "%", + cx, + ))) + .child(ui::Field::plain(&theme, "Duration").child(self.slider( + SliderKey::Cursor(CursorSlider::RippleDuration), + "secs", + cx, + ))) + .into_any_element(), + )) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cards_lead_with_the_host_family() { + let cards = cursor_cards(); + assert_eq!(cards[0], CursorCard::Family(host_cursor_family())); + assert_eq!(cards[3], CursorCard::Circle); + for family in [ + CursorFamily::MacOS, + CursorFamily::MacOSTahoe, + CursorFamily::Windows, + ] { + assert!(cards.contains(&CursorCard::Family(family)), "{family:?}"); + } + } + + /// Every card writes a type that resolves back to the card it was drawn + /// on, which is what keeps the selected ring on the card just clicked. + #[test] + fn every_card_round_trips_through_its_type() { + for card in cursor_cards() { + let written = card.cursor_type(); + assert_ne!(written, CursorType::Auto); + assert_eq!(selected_card(&written, None), card, "{:?}", card.label()); + } + } + + #[test] + fn auto_follows_the_recording_then_the_host() { + assert_eq!( + selected_card(&CursorType::Auto, Some(CursorFamily::MacOSTahoe)), + CursorCard::Family(CursorFamily::MacOSTahoe) + ); + // The legacy value renders exactly as `Auto` does. + assert_eq!( + selected_card(&CursorType::Pointer, Some(CursorFamily::Windows)), + CursorCard::Family(CursorFamily::Windows) + ); + assert_eq!( + selected_card(&CursorType::Auto, None), + CursorCard::Family(host_cursor_family()) + ); + } + + /// Every family's arrow resolves and rasterises at the tile's box (at 1x + /// and 2x) -- a card with a missing asset would silently draw an empty + /// tile. + #[test] + fn every_arrow_rasterises() { + for family in [ + CursorFamily::MacOS, + CursorFamily::MacOSTahoe, + CursorFamily::Windows, + ] { + let shape = family.arrow(); + for side in [ARROW_BOX as u32, (ARROW_BOX * 2.) as u32] { + let image = rasterize_cursor(shape, side, side) + .unwrap_or_else(|| panic!("{shape} at {side}px")); + let size = image.size(0); + assert_eq!(size.width.0 as u32, side); + assert_eq!(size.height.0 as u32, side); + let bytes = image.as_bytes(0).expect("one frame"); + assert!( + bytes.chunks_exact(4).any(|pixel| pixel[3] > 0), + "{shape} rasterised to nothing" + ); + } + } + } +} diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index f5c9e9fc2a9..eb7c660a0a2 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -32,8 +32,8 @@ use std::{ use cap_project::CaptionsData; use cap_project::{ BackgroundBlurConfig, BackgroundBlurMode, CameraShape, CameraXPosition, CameraYPosition, - CaptionSegment, CaptionSettings, CornerStyle, CursorAnimationStyle, KeyboardData, - KeyboardSettings, ProjectConfiguration, ShadowConfiguration, StereoMode, + CaptionSegment, CaptionSettings, CornerStyle, CursorAnimationStyle, CursorRippleConfig, + KeyboardData, KeyboardSettings, ProjectConfiguration, ShadowConfiguration, StereoMode, }; use gpui::{ AnyElement, Context, EntityId, FontWeight, Hsla, InteractiveElement, IntoElement, @@ -79,13 +79,6 @@ pub const STEREO_MODES: [(StereoMode, &str); 3] = [ (StereoMode::MonoR, "Mono R"), ]; -/// `CURSOR_TYPE_OPTIONS` (`:421-433`). -pub const CURSOR_TYPES: [(&str, &str, &str); 3] = [ - ("auto", "Auto", "Use the cursor Cap recorded"), - ("pointer", "Pointer", "Always draw the standard arrow"), - ("circle", "Circle", "Draw a simple circle instead"), -]; - /// `CURSOR_ANIMATION_STYLE_OPTIONS` (`:434-476`) -- the four presets plus the /// `custom` value the physics sliders drop the picker into. pub const CURSOR_STYLES: [(CursorAnimationStyle, &str, &str); 4] = [ @@ -421,6 +414,9 @@ pub enum CursorSlider { Tension, Friction, Mass, + RippleStrength, + RippleSize, + RippleDuration, } impl CursorSlider { @@ -432,6 +428,15 @@ impl CursorSlider { Self::Tension => (1., 600., 1.), Self::Friction => (0., 200., 0.1), Self::Mass => (0.1, 15., 0.01), + // Strength and size are stored as fractions and shown as + // percentages; `CursorRippleConfig`'s own ranges, x100. + Self::RippleStrength => (0., 100., 1.), + Self::RippleSize => (25., 300., 1.), + Self::RippleDuration => ( + CursorRippleConfig::DURATION_RANGE.0, + CursorRippleConfig::DURATION_RANGE.1, + 0.05, + ), } } @@ -444,6 +449,9 @@ impl CursorSlider { Self::Tension => cursor.tension, Self::Friction => cursor.friction, Self::Mass => cursor.mass, + Self::RippleStrength => cursor.ripple.strength_clamped() * 100., + Self::RippleSize => cursor.ripple.size_clamped() * 100., + Self::RippleDuration => cursor.ripple.duration_clamped(), } } } @@ -1046,6 +1054,9 @@ impl EditorWindow { match_cursor_preset(cursor.tension, cursor.mass, cursor.friction) .unwrap_or(CursorAnimationStyle::Custom); } + CursorSlider::RippleStrength => cursor.ripple.strength = value / 100., + CursorSlider::RippleSize => cursor.ripple.size = value / 100., + CursorSlider::RippleDuration => cursor.ripple.duration = value, } true }); @@ -1628,10 +1639,6 @@ impl EditorWindow { return body.into_any_element(); } - let cursor_type = format!("{:?}", cursor.cursor_type()).to_lowercase(); - let type_index = CURSOR_TYPES - .iter() - .position(|(value, _, _)| *value == cursor_type); let style_index = CURSOR_STYLES .iter() .position(|(style, _, _)| *style == cursor.animation_style); @@ -1639,29 +1646,9 @@ impl EditorWindow { body = body .child( - ui::Field::plain(&theme, "Cursor Type") + ui::Field::plain(&theme, "Cursor Style") .icon("icons/cursor.svg") - .child( - ui::RadioCards::plain( - &theme, - "cursor-type", - CURSOR_TYPES - .iter() - .map(|(_, label, description)| { - ui::RadioCard::new(*label, Some(description)) - }) - .collect(), - type_index, - ) - .on_select(cx.listener( - |this, index: &usize, window, cx| { - let Some((value, _, _)) = CURSOR_TYPES.get(*index) else { - return; - }; - this.set_cursor_type(value, window, cx); - }, - )), - ), + .child(self.render_cursor_style_picker(cx)), ) .child( ui::Field::plain(&theme, "Size") @@ -1673,6 +1660,7 @@ impl EditorWindow { .icon("icons/rotate-3d.svg") .child(self.slider(SliderKey::Cursor(CursorSlider::Tilt), "x100%", cx)), ) + .child(self.render_cursor_ripple(cx)) .child( ui::Field::plain(&theme, "Hide When Idle") .icon("icons/timer.svg") @@ -1719,136 +1707,112 @@ impl EditorWindow { } let smooth = !cursor.raw; - body.child( - ui::Field::plain(&theme, "Cursor Movement Style") - .icon("icons/rabbit.svg") - .child( - ui::RadioCards::plain( - &theme, - "cursor-style", - CURSOR_STYLES - .iter() - .map(|(_, label, description)| { - ui::RadioCard::new(*label, Some(description)) - }) - .collect(), - style_index, + let mut body = body + .child( + ui::Field::plain(&theme, "Cursor Movement Style") + .icon("icons/rabbit.svg") + .child( + ui::RadioCards::plain( + &theme, + "cursor-style", + CURSOR_STYLES + .iter() + .map(|(_, label, description)| { + ui::RadioCard::new(*label, Some(description)) + }) + .collect(), + style_index, + ) + .on_select(cx.listener( + |this, index: &usize, window, cx| { + let Some((style, _, _)) = CURSOR_STYLES.get(*index) else { + return; + }; + let style = *style; + // `applyCursorStylePreset` (`:551-561`): the style and + // its three physics values, in one batch. + this.edit_project("cursor-style", window, cx, move |project| { + project.cursor.animation_style = style; + if let Some(preset) = style.preset() { + project.cursor.tension = preset.tension; + project.cursor.mass = preset.mass; + project.cursor.friction = preset.friction; + } + true + }); + }, + )), + ), + ) + .child( + div() + .flex() + .flex_col() + .child( + ui::Field::plain(&theme, "Smooth Movement") + .icon("icons/ease-curve.svg") + .value( + ui::Toggle::plain(&theme, "cursor-smooth", smooth) + .on_click(cx.listener(move |this, _, window, cx| { + this.sidebar.cursor_physics_open.set_open(!smooth); + this.animate_collapsibles(window, cx); + this.edit_project("cursor-raw", window, cx, move |p| { + p.cursor.raw = smooth; + true + }); + })) + .into_any_element(), + ), ) - .on_select(cx.listener( - |this, index: &usize, window, cx| { - let Some((style, _, _)) = CURSOR_STYLES.get(*index) else { - return; - }; - let style = *style; - // `applyCursorStylePreset` (`:551-561`): the style and - // its three physics values, in one batch. - this.edit_project("cursor-style", window, cx, move |project| { - project.cursor.animation_style = style; - if let Some(preset) = style.preset() { - project.cursor.tension = preset.tension; - project.cursor.mass = preset.mass; - project.cursor.friction = preset.friction; - } - true - }); - }, + .child(collapsible( + &self.sidebar.cursor_physics_open, + div() + .flex() + .flex_col() + .gap(px(16.)) + // `pt-4 pb-6` + .pt(px(16.)) + .pb(px(24.)) + .child(ui::Field::plain(&theme, "Tension").child(self.slider( + SliderKey::Cursor(CursorSlider::Tension), + "", + cx, + ))) + .child(ui::Field::plain(&theme, "Friction").child(self.slider( + SliderKey::Cursor(CursorSlider::Friction), + "", + cx, + ))) + .child(ui::Field::plain(&theme, "Mass").child(self.slider( + SliderKey::Cursor(CursorSlider::Mass), + "", + cx, + ))) + .into_any_element(), )), - ), - ) - .child( - div() - .flex() - .flex_col() - .child( - ui::Field::plain(&theme, "Smooth Movement") - .icon("icons/ease-curve.svg") - .value( - ui::Toggle::plain(&theme, "cursor-smooth", smooth) - .on_click(cx.listener(move |this, _, window, cx| { - this.sidebar.cursor_physics_open.set_open(!smooth); - this.animate_collapsibles(window, cx); - this.edit_project("cursor-raw", window, cx, move |p| { - p.cursor.raw = smooth; - true - }); - })) - .into_any_element(), - ), - ) - .child(collapsible( - &self.sidebar.cursor_physics_open, - div() - .flex() - .flex_col() - .gap(px(16.)) - // `pt-4 pb-6` - .pt(px(16.)) - .pb(px(24.)) - .child(ui::Field::plain(&theme, "Tension").child(self.slider( - SliderKey::Cursor(CursorSlider::Tension), - "", - cx, - ))) - .child(ui::Field::plain(&theme, "Friction").child(self.slider( - SliderKey::Cursor(CursorSlider::Friction), - "", - cx, - ))) - .child(ui::Field::plain(&theme, "Mass").child(self.slider( - SliderKey::Cursor(CursorSlider::Mass), - "", - cx, - ))) - .into_any_element(), - )), - ) - .child( - ui::Field::plain(&theme, "High Quality SVG Cursors") - .icon("icons/sparkles.svg") - .value( - ui::Toggle::plain(&theme, "cursor-svg", cursor.use_svg) - .on_click(cx.listener(|this, _, window, cx| { - let next = !this.project.cursor.use_svg; - this.edit_project("cursor-svg", window, cx, move |p| { - p.cursor.use_svg = next; - true - }); - })) - .into_any_element(), - ), - ) - .into_any_element() - } + ); - /// `CursorConfiguration::type` is private in `cap-project` with a getter - /// and no setter, and this app does not edit the shared crate -- so the - /// write goes through serde, which is the field's public surface. One - /// round trip per click of a radio card costs nothing. - fn set_cursor_type( - &mut self, - value: &'static str, - window: &mut Window, - cx: &mut Context, - ) { - self.edit_project("cursor-type", window, cx, move |project| { - let Ok(mut json) = serde_json::to_value(&project.cursor) else { - return false; - }; - let Some(object) = json.as_object_mut() else { - return false; - }; - if object.get("type").and_then(|value| value.as_str()) == Some(value) { - return false; - } - object.insert("type".into(), serde_json::Value::String(value.into())); - match serde_json::from_value(json) { - Ok(cursor) => { - project.cursor = cursor; - true - } - Err(_) => false, - } - }); + // An explicit family forces the SVG assets, so the toggle would be + // showing a setting the renderer is already overriding. + if cursor.cursor_type().family().is_none() { + body = body.child( + ui::Field::plain(&theme, "High Quality SVG Cursors") + .icon("icons/sparkles.svg") + .value( + ui::Toggle::plain(&theme, "cursor-svg", cursor.use_svg) + .on_click(cx.listener(|this, _, window, cx| { + let next = !this.project.cursor.use_svg; + this.edit_project("cursor-svg", window, cx, move |p| { + p.cursor.use_svg = next; + true + }); + })) + .into_any_element(), + ), + ); + } + + body.into_any_element() } // -- Keyboard ------------------------------------------------------------ diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 6b8517ab3bb..4e74585178c 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -47,9 +47,10 @@ use std::{ time::{Duration, Instant}, }; +use cap_cursor_info::CursorFamily; use cap_editor::{EditorFrameOutput, EditorInstance, EditorState}; use cap_project::{ - ClipSpeedAudioMode, ProjectConfiguration, RecordingMeta, RecordingMetaInner, + ClipSpeedAudioMode, Cursors, ProjectConfiguration, RecordingMeta, RecordingMetaInner, StudioRecordingMeta, TimelineConfiguration, XY, }; use cap_rendering::{FrameLayout, ProjectRecordingsMeta, RenderedFrame}; @@ -237,6 +238,10 @@ pub struct ProjectSummary { /// The cursor tab is disabled on `!meta().hasRecordedCursorData` /// (`ConfigSidebar.tsx:610`). pub has_cursor_data: bool, + /// The asset family the recorded cursor shapes belong to, which the style + /// picker highlights as "Recorded" and falls back to while the project's + /// own type is `Auto`. + pub recorded_cursor_family: Option, /// `editorInstance.recordings.segments[i].display.duration` -- the ceiling /// a clip's end handle trims out to (`TL/ClipTrack.tsx:1160-1162`). pub clip_display_durations: Vec, @@ -374,6 +379,7 @@ pub fn preflight(path: &std::path::Path) -> Result { duration: duration.max(0.0), has_camera, has_cursor_data: has_recorded_cursor_data(&meta, studio.as_ref()), + recorded_cursor_family: recorded_cursor_family(studio.as_ref()), clip_display_durations: recordings .segments .iter() @@ -440,6 +446,25 @@ fn has_recorded_cursor_data(meta: &RecordingMeta, studio: &StudioRecordingMeta) } } +/// The family the recording's own cursor shapes belong to. +/// +/// Keyed by cursor id so the answer does not depend on `HashMap` iteration +/// order: a bundle whose shapes span two families would otherwise pick a +/// different card between two opens of the same recording. +fn recorded_cursor_family(studio: &StudioRecordingMeta) -> Option { + let StudioRecordingMeta::MultipleSegments { inner } = studio else { + return None; + }; + let Cursors::Correct(cursors) = &inner.cursors else { + return None; + }; + let mut ids: Vec<_> = cursors.keys().collect(); + ids.sort(); + ids.into_iter() + .find_map(|id| cursors.get(id).and_then(|cursor| cursor.shape)) + .map(|shape| shape.family()) +} + /// One rendered frame, lifted into gpui's sprite atlas. /// /// Two conversions, both mandatory: @@ -1310,6 +1335,9 @@ pub struct EditorWindow { /// model can be rebuilt after every edit. has_camera: bool, multiple_clips: bool, + /// `ProjectSummary::recorded_cursor_family`, kept here because the cursor + /// tab renders long before (and independently of) the load state's box. + pub(crate) recorded_cursor_family: Option, /// The debounced `project-config.json` write, and the task driving it. pending_save: Rc>, save_task: Option>, @@ -1620,6 +1648,7 @@ impl EditorWindow { recording_duration: 0.0, has_camera: false, multiple_clips: false, + recorded_cursor_family: None, pending_save: Rc::new(RefCell::new(PendingProjectSave::default())), save_task: None, name_input, @@ -1709,6 +1738,7 @@ impl EditorWindow { self.recording_duration = summary.recording_duration; self.has_camera = summary.has_camera; self.multiple_clips = summary.multiple_clips; + self.recorded_cursor_family = summary.recorded_cursor_family; self.pending_save.borrow_mut().path = Some(self.project_path.clone()); // `zoom: zoomOutLimit()` is the store's *initial* value // (`ED/context.ts:1455`), so it is set the moment a duration exists -- @@ -8336,6 +8366,7 @@ impl Render for EditorWindow { // a brand-new box empty until something else asked for a frame. self.prepare_sidebar_fields(window, cx); self.prepare_animated_gradient_fields(window, cx); + self.prepare_cursor_fields(window, cx); self.sync_hex_inputs(window, cx); self.sync_picker_hex(window, cx); self.sync_crop_container(window); From 8a79be9a73f5eb635dde6c274751ee0a01dab26c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:39:30 +0100 Subject: [PATCH 07/10] feat: add frame controls to the GPUI editor --- apps/desktop-gpui/assets/icons/app-window.svg | 1 + apps/desktop-gpui/assets/icons/ban.svg | 1 + apps/desktop-gpui/assets/icons/globe.svg | 1 + apps/desktop-gpui/src/assets.rs | 4 + apps/desktop-gpui/src/editor_window.rs | 19 +- apps/desktop-gpui/src/editor_window/frame.rs | 626 ++++++++++++++++++ 6 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 apps/desktop-gpui/assets/icons/app-window.svg create mode 100644 apps/desktop-gpui/assets/icons/ban.svg create mode 100644 apps/desktop-gpui/assets/icons/globe.svg create mode 100644 apps/desktop-gpui/src/editor_window/frame.rs diff --git a/apps/desktop-gpui/assets/icons/app-window.svg b/apps/desktop-gpui/assets/icons/app-window.svg new file mode 100644 index 00000000000..a0fabfa160c --- /dev/null +++ b/apps/desktop-gpui/assets/icons/app-window.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/assets/icons/ban.svg b/apps/desktop-gpui/assets/icons/ban.svg new file mode 100644 index 00000000000..07d5ba3a65e --- /dev/null +++ b/apps/desktop-gpui/assets/icons/ban.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/assets/icons/globe.svg b/apps/desktop-gpui/assets/icons/globe.svg new file mode 100644 index 00000000000..57b0dfc7687 --- /dev/null +++ b/apps/desktop-gpui/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index e42ea95f33c..8f0c3a30c78 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -39,6 +39,9 @@ const FONTS: &[(&str, &[u8])] = assets!("fonts": /// illustration would come out as a filled silhouette. const ICONS: &[(&str, &[u8])] = assets!("icons": "app-window-mac.svg", + "app-window.svg", + "ban.svg", + "globe.svg", "area.svg", "arrows.svg", "audio-on.svg", @@ -316,6 +319,7 @@ mod tests { include_str!("mode_select_window.rs"), include_str!("teleprompter_window.rs"), include_str!("editor_window.rs"), + include_str!("editor_window/frame.rs"), // The timeline's nine track glyphs and its scene-mode icons are named // in the strip's own module, not in the window that hosts it. include_str!("editor_timeline.rs"), diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 4e74585178c..638f98fe2ca 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -74,6 +74,8 @@ use crate::{ ui, }; +mod frame; + // --------------------------------------------------------------------------- // Window geometry // --------------------------------------------------------------------------- @@ -1411,6 +1413,7 @@ pub struct EditorWindow { preview_quality: crate::store::EditorPreviewQuality, pub(crate) tracks: TrackLanes, toolbar_menu: Option, + frame_controls: frame::FrameControls, add_track: Option, pub(crate) audio_picker: Option, pub(crate) camera3d_setup: Option, @@ -1674,6 +1677,7 @@ impl EditorWindow { preview_quality: crate::store::GeneralSettings::load().editor_preview_quality, tracks: TrackLanes::from_project(&ProjectConfiguration::default(), false), toolbar_menu: None, + frame_controls: frame::FrameControls::default(), add_track: None, audio_picker: None, camera3d_setup: None, @@ -2370,6 +2374,11 @@ impl EditorWindow { /// (`useEditorShortcuts.ts:10`) and `e.repeat` is ignored there /// (`:42`) as `is_held` is here. fn on_key(&mut self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context) { + if self.frame_controls.is_open() && event.keystroke.key == "escape" { + self.close_frame_controls(window, cx); + cx.stop_propagation(); + return; + } // Crop mode first. It takes Escape and the four arrows and lets // **everything else through**, which is what the source does: the // dialog is a Kobalte modal but `useEditorShortcuts` and the @@ -7336,13 +7345,7 @@ impl EditorWindow { this.open_crop(window, cx); })), ) - // `FrameButton`, whose idle label is "Frame". - .child(self.editor_button( - "icons/app-window-mac.svg", - Some("Frame"), - None, - None, - )), + .child(self.render_frame_button(cx)), ) .child( div() @@ -8369,6 +8372,7 @@ impl Render for EditorWindow { self.prepare_cursor_fields(window, cx); self.sync_hex_inputs(window, cx); self.sync_picker_hex(window, cx); + self.prepare_frame_fields(window, cx); self.sync_crop_container(window); let theme = self.theme; // The timeline's own bounds are what `secsPerPixel` divides by, and @@ -8679,6 +8683,7 @@ impl Render for EditorWindow { // sidebar and the drag layers alike. .children(self.render_sidebar_menu(cx)) .children(self.render_toolbar_menu(cx)) + .children(self.render_frame_controls(window, cx)) .children(self.render_add_track_popover(cx)) .children(self.render_clip_speed_popover(cx)) .children(self.render_color_picker_popover(cx)) diff --git a/apps/desktop-gpui/src/editor_window/frame.rs b/apps/desktop-gpui/src/editor_window/frame.rs new file mode 100644 index 00000000000..111fb2b4244 --- /dev/null +++ b/apps/desktop-gpui/src/editor_window/frame.rs @@ -0,0 +1,626 @@ +use cap_project::{FrameConfiguration, FrameStyle, FrameTheme, ProjectConfiguration}; +use gpui::{ + AppContext as _, Context, Entity, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, StatefulInteractiveElement as _, Styled, Window, div, point, + prelude::FluentBuilder, px, svg, +}; + +use super::EditorWindow; +use crate::ui; + +const FRAME_STYLES: [(FrameStyle, &str, &str, &str); 5] = [ + ( + FrameStyle::None, + "None", + "Show the recording as-is", + "icons/ban.svg", + ), + ( + FrameStyle::MacOS, + "macOS", + "Window chrome with traffic lights", + "icons/app-window-mac.svg", + ), + ( + FrameStyle::Windows, + "Windows", + "Title bar with window controls", + "icons/app-window.svg", + ), + ( + FrameStyle::Browser, + "Browser", + "Browser chrome with address bar", + "icons/globe.svg", + ), + ( + FrameStyle::Macbook, + "MacBook", + "Laptop bezel around the recording", + "icons/laptop.svg", + ), +]; + +#[derive(Default)] +pub(super) struct FrameControls { + open: bool, + trigger_bounds: ui::SliderTrack, + fields: Option<[Entity; 2]>, + editing: Option, +} + +impl FrameControls { + pub(super) fn is_open(&self) -> bool { + self.open + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FrameField { + Url, + Title, +} + +impl FrameField { + fn for_style(style: FrameStyle) -> Option { + match style { + FrameStyle::Browser => Some(Self::Url), + FrameStyle::MacOS => Some(Self::Title), + _ => None, + } + } + + fn index(self) -> usize { + match self { + Self::Url => 0, + Self::Title => 1, + } + } + + fn label(self) -> &'static str { + match self { + Self::Url => "URL", + Self::Title => "Title", + } + } + + fn text(self, frame: &FrameConfiguration) -> &str { + match self { + Self::Url => &frame.url, + Self::Title => &frame.title, + } + } +} + +enum FrameChange { + Style(FrameStyle), + Theme(FrameTheme), + Text(FrameField, String), +} + +fn apply_frame_change(project: &mut ProjectConfiguration, change: FrameChange) -> bool { + let previous = project.background.frame.clone(); + let frame = project + .background + .frame + .get_or_insert_with(Default::default); + match change { + FrameChange::Style(style) => frame.style = style, + FrameChange::Theme(theme) => frame.theme = theme, + FrameChange::Text(FrameField::Url, text) => frame.url = text, + FrameChange::Text(FrameField::Title, text) => frame.title = text, + } + project.background.frame != previous +} + +fn button_content(style: FrameStyle) -> (&'static str, &'static str) { + if style == FrameStyle::None { + return ("Frame", "icons/app-window-mac.svg"); + } + FRAME_STYLES + .iter() + .find(|option| option.0 == style) + .map(|option| (option.1, option.3)) + .unwrap_or(("Frame", "icons/app-window-mac.svg")) +} + +impl EditorWindow { + fn frame_style(&self) -> FrameStyle { + FrameConfiguration::active_style(self.project.background.frame.as_ref()) + } + + pub(super) fn render_frame_button(&self, cx: &mut Context) -> impl IntoElement { + let (label, icon) = button_content(self.frame_style()); + let bounds = self.frame_controls.trigger_bounds.clone(); + div() + .relative() + .flex_none() + .child( + ui::EditorButton::plain(&self.theme, "frame-settings") + .left_icon(icon) + .right_icon("icons/chevron-down.svg") + .label(label) + .tooltip(&self.theme, "Add a frame") + .pressed(self.frame_controls.open) + .disabled(self.instance.is_none()) + .on_click(cx.listener(|this, _, window, cx| { + if this.frame_controls.open { + this.close_frame_controls(window, cx); + } else { + this.focus_root(window, cx); + this.toolbar_menu = None; + this.add_track = None; + this.frame_controls.open = true; + cx.notify(); + } + })), + ) + .child( + gpui::canvas(move |rect, _, _| bounds.set(Some(rect)), |_, _, _, _| {}) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + } + + pub(super) fn prepare_frame_fields(&mut self, window: &mut Window, cx: &mut Context) { + if !self.frame_controls.open { + return; + } + if self.frame_controls.fields.is_none() { + self.frame_controls.fields = Some([FrameField::Url, FrameField::Title].map(|field| { + let input = cx.new(|cx| { + let mut input = ui::TextInputState::single_line(window, cx); + input.set_placeholder(match field { + FrameField::Url => "cap.so", + FrameField::Title => "Window title", + }); + input + }); + let subscription = + cx.subscribe_in(&input, window, move |this, input, event, window, cx| { + this.on_frame_field_event(field, input, event, window, cx); + }); + self.push_text_subscription(subscription); + input + })); + } + let frame = self.project.background.frame.clone().unwrap_or_default(); + if let Some(inputs) = &self.frame_controls.fields { + for field in [FrameField::Url, FrameField::Title] { + let input = &inputs[field.index()]; + if !input.read(cx).focus_handle().is_focused(window) { + input.update(cx, |input, cx| { + input.set_text(field.text(&frame).to_owned(), cx) + }); + } + } + } + } + + fn on_frame_field_event( + &mut self, + field: FrameField, + input: &Entity, + event: &ui::TextInputEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ui::TextInputEvent::Changed => { + if !self.frame_controls.open + || FrameField::for_style(self.frame_style()) != Some(field) + { + return; + } + if self.frame_controls.editing != Some(field) { + self.finish_frame_text_edit(cx); + self.history.pause(); + self.frame_controls.editing = Some(field); + } + let change = FrameChange::Text(field, input.read(cx).text().to_owned()); + self.edit_project("frame-text", window, cx, |project| { + apply_frame_change(project, change) + }); + } + ui::TextInputEvent::Blurred => self.finish_frame_text_edit(cx), + ui::TextInputEvent::Confirmed => { + self.finish_frame_text_edit(cx); + self.focus_root(window, cx); + } + ui::TextInputEvent::Cancelled => self.close_frame_controls(window, cx), + } + } + + fn finish_frame_text_edit(&mut self, cx: &mut Context) { + if self.frame_controls.editing.take().is_some() { + self.history.resume(&self.project); + cx.notify(); + } + } + + pub(super) fn close_frame_controls(&mut self, window: &mut Window, cx: &mut Context) { + self.finish_frame_text_edit(cx); + self.frame_controls.open = false; + self.focus_root(window, cx); + cx.notify(); + } + + fn change_frame(&mut self, change: FrameChange, window: &mut Window, cx: &mut Context) { + self.finish_frame_text_edit(cx); + self.focus_root(window, cx); + self.edit_project("frame", window, cx, |project| { + apply_frame_change(project, change) + }); + } + + pub(super) fn render_frame_controls( + &self, + window: &Window, + cx: &mut Context, + ) -> Option { + if !self.frame_controls.open { + return None; + } + let bounds = self.frame_controls.trigger_bounds.get()?; + let theme = self.theme; + let style = self.frame_style(); + let frame_theme = self + .project + .background + .frame + .as_ref() + .map_or(FrameTheme::Dark, |frame| frame.theme); + let panel = + div() + .id("frame-popover") + .occlude() + .flex() + .flex_col() + .w(px(304.).min(window.viewport_size().width - px(24.))) + .max_h(window.viewport_size().height - px(24.)) + .overflow_y_scroll() + .rounded(px(16.)) + .border_1() + .border_color(theme.gray(3)) + .bg(theme.gray(1)) + .shadow_lg() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .flex() + .flex_col() + .gap(px(2.)) + .px(px(16.)) + .pt(px(14.)) + .pb(px(12.)) + .border_b_1() + .border_color(theme.gray(3)) + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.gray(12)) + .child("Frame"), + ) + .child( + div() + .text_size(px(11.)) + .text_color(theme.gray(10)) + .child("Wrap your recording in a window or device frame."), + ), + ) + .child(div().flex().flex_col().gap(px(2.)).p(px(6.)).children( + FRAME_STYLES.into_iter().enumerate().map( + |(index, (value, label, description, icon))| { + let selected = value == style; + div() + .id(("frame-style", index)) + .tab_index(0) + .flex() + .items_center() + .gap(px(12.)) + .p(px(8.)) + .rounded(px(12.)) + .cursor_pointer() + .hover(|row| row.bg(theme.gray(3))) + .focus_visible(|row| row.bg(theme.gray(3))) + .child( + div() + .flex() + .items_center() + .justify_center() + .size(px(32.)) + .flex_shrink_0() + .rounded(px(10.)) + .bg(if selected { + theme.blue_9.into() + } else { + theme.gray(3) + }) + .child(svg().path(icon).size(px(16.)).text_color( + if selected { + gpui::white() + } else { + theme.gray(11) + }, + )), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray(12)) + .child(label), + ) + .child( + div() + .text_size(px(11.)) + .text_color(theme.gray(10)) + .child(description), + ), + ) + .when(selected, |row| { + row.child( + svg() + .path("icons/circle-check.svg") + .size(px(16.)) + .flex_shrink_0() + .text_color(theme.blue_9), + ) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.change_frame(FrameChange::Style(value), window, cx) + })) + }, + ), + )) + .when(style != FrameStyle::None, |panel| { + panel.child( + div() + .flex() + .flex_col() + .gap(px(12.)) + .p(px(12.)) + .border_t_1() + .border_color(theme.gray(3)) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap(px(12.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray(11)) + .child("Theme"), + ) + .child( + div() + .flex() + .w(px(160.)) + .h(px(32.)) + .border_1() + .border_color(theme.gray(3)) + .rounded(px(8.)) + .p(px(1.)) + .children( + [ + (FrameTheme::Light, "Light"), + (FrameTheme::Dark, "Dark"), + ] + .into_iter() + .map(|(value, label)| { + div() + .id(( + "frame-theme", + usize::from(value == FrameTheme::Dark), + )) + .tab_index(0) + .flex() + .flex_1() + .items_center() + .justify_center() + .rounded(px(7.)) + .text_size(px(12.)) + .text_color(if value == frame_theme { + theme.gray(12) + } else { + theme.gray(11) + }) + .when(value == frame_theme, |tab| { + tab.bg(theme.gray(3)) + }) + .focus_visible(|tab| tab.bg(theme.gray(4))) + .cursor_pointer() + .child(label) + .on_click(cx.listener( + move |this, _, window, cx| { + this.change_frame( + FrameChange::Theme(value), + window, + cx, + ) + }, + )) + }), + ), + ), + ) + .children(FrameField::for_style(style).and_then(|field| { + let input = &self.frame_controls.fields.as_ref()?[field.index()]; + Some( + div() + .flex() + .items_center() + .justify_between() + .gap(px(12.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.gray(11)) + .child(field.label()), + ) + .child( + ui::TextInput::plain( + &theme, + ("frame-text", field.index()), + input, + ) + .width(px(160.)) + .height(px(32.)) + .radius(px(8.)) + .bg(theme.gray(2)), + ), + ) + })), + ) + }); + Some( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .child( + div() + .id("frame-backdrop") + .absolute() + .top_0() + .left_0() + .size_full() + .occlude() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.close_frame_controls(window, cx); + cx.stop_propagation(); + }), + ), + ) + .child( + gpui::anchored() + .position(point(bounds.left(), bounds.bottom() + px(8.))) + .snap_to_window_with_margin(px(12.)) + .child(panel), + ) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor_edits::ProjectHistory; + + #[test] + fn frame_styles_and_button_labels_match_tauri() { + assert_eq!( + FRAME_STYLES.map(|option| option.1), + ["None", "macOS", "Windows", "Browser", "MacBook"] + ); + assert_eq!( + button_content(FrameStyle::None), + ("Frame", "icons/app-window-mac.svg") + ); + for (style, label, _, icon) in FRAME_STYLES.into_iter().skip(1) { + assert_eq!(button_content(style), (label, icon)); + } + } + + #[test] + fn frame_style_initializes_matching_defaults_and_keeps_existing_settings() { + let mut project = ProjectConfiguration::default(); + assert!(apply_frame_change( + &mut project, + FrameChange::Style(FrameStyle::Browser) + )); + let frame = project.background.frame.as_ref().unwrap(); + assert_eq!(frame.theme, FrameTheme::Dark); + assert_eq!(frame.url, "Cap.so"); + assert_eq!(frame.title, ""); + assert!(!apply_frame_change( + &mut project, + FrameChange::Style(FrameStyle::Browser) + )); + assert!(apply_frame_change( + &mut project, + FrameChange::Text(FrameField::Url, "example.com".into()) + )); + assert!(apply_frame_change( + &mut project, + FrameChange::Text(FrameField::Title, "Demo".into()) + )); + assert!(apply_frame_change( + &mut project, + FrameChange::Theme(FrameTheme::Light) + )); + for (style, _, _, _) in FRAME_STYLES { + apply_frame_change(&mut project, FrameChange::Style(style)); + let frame = project.background.frame.as_ref().unwrap(); + assert_eq!(frame.url, "example.com"); + assert_eq!(frame.title, "Demo"); + assert_eq!(frame.theme, FrameTheme::Light); + } + } + + #[test] + fn frame_text_fields_match_tauri_visibility() { + assert!(FrameField::for_style(FrameStyle::Browser) == Some(FrameField::Url)); + assert!(FrameField::for_style(FrameStyle::MacOS) == Some(FrameField::Title)); + for style in [FrameStyle::None, FrameStyle::Windows, FrameStyle::Macbook] { + assert!(FrameField::for_style(style).is_none()); + } + } + + #[test] + fn frame_changes_round_trip_and_undo_without_changing_the_background() { + let mut project = ProjectConfiguration::default(); + let source = serde_json::to_value(&project.background.source).unwrap(); + let mut history = ProjectHistory::new(project.clone()); + for (style, _, _, _) in FRAME_STYLES { + apply_frame_change(&mut project, FrameChange::Style(style)); + history.record(&project); + let saved = serde_json::to_vec(&project).unwrap(); + let loaded: ProjectConfiguration = serde_json::from_slice(&saved).unwrap(); + assert_eq!(loaded.background.frame, project.background.frame); + assert_eq!( + serde_json::to_value(&loaded.background.source).unwrap(), + source + ); + } + assert_eq!( + history + .undo() + .unwrap() + .background + .frame + .as_ref() + .unwrap() + .style, + FrameStyle::Browser + ); + assert_eq!( + history + .redo() + .unwrap() + .background + .frame + .as_ref() + .unwrap() + .style, + FrameStyle::Macbook + ); + } +} From 279e7ecf7258e979602f2e43a2febaaff1451c05 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:39:30 +0100 Subject: [PATCH 08/10] fix: reserve Space for GPUI editor playback --- apps/desktop-gpui/src/editor_window.rs | 47 +++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 638f98fe2ca..bdca2a1cb30 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -2368,6 +2368,24 @@ impl EditorWindow { self.stop_playback(cx); } + fn capture_playback_key( + &mut self, + event: &gpui::KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if !is_playback_shortcut(&event.keystroke, ui::text_input_has_focus(window, cx)) { + return; + } + // Focused GPUI buttons arm a second click on key-up unless Space is + // consumed before their key-down listener runs. + window.prevent_default(); + cx.stop_propagation(); + if !event.is_held { + self.toggle_play(window, cx); + } + } + /// The editor's key bindings live in `useEditorShortcuts` /// (`Player.tsx:236-286`): `Space` play/pause, `S` split (E4's) and /// `Mod+=` / `Mod+-` zoom. `Mod` is Cmd-or-Ctrl @@ -2573,10 +2591,6 @@ impl EditorWindow { return; } match keystroke.key.as_str() { - "space" => { - cx.stop_propagation(); - self.toggle_play(window, cx); - } // `e.code === "Backspace" || (e.code === "Delete" && // hasNoModifiers)` (`TL/index.tsx:963`). gpui reports the main // delete key as `backspace` and forward-delete as `delete`, which @@ -2816,6 +2830,7 @@ impl EditorWindow { if event.button != MouseButton::Left || self.transport.is_none() { return; } + self.focus_root(window, cx); let viewport_width: f32 = window.viewport_size().width.into(); let time = self.time_at(f32::from(event.position.x), viewport_width); if ruler { @@ -2929,6 +2944,7 @@ impl EditorWindow { if event.button != MouseButton::Left || self.transport.is_none() { return; } + self.focus_root(window, cx); if self.clip_anim.is_some() { self.clip_anim = None; self.clip_anim_generation += 1; @@ -8361,6 +8377,10 @@ fn playhead_extrapolation(playing: bool, epoch_has_sample: bool, since_last_samp since_last_sample.clamp(0.0, MAX_PLAYHEAD_EXTRAPOLATION) } +fn is_playback_shortcut(keystroke: &gpui::Keystroke, text_input_focused: bool) -> bool { + keystroke.key == "space" && !keystroke.modifiers.modified() && !text_input_focused +} + impl Render for EditorWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_appearance(window, cx); @@ -8414,6 +8434,7 @@ impl Render for EditorWindow { .bg(self.root_bg()) .text_color(Hsla::from(theme.gray_12)) .track_focus(&self.focus) + .capture_key_down(cx.listener(Self::capture_playback_key)) .on_key_down(cx.listener(Self::on_key)) // Only the cropper needs key-*up*: its nudge loop runs until every // arrow is released (`Cropper.tsx:1025-1051`). @@ -8875,6 +8896,24 @@ fn hex_to_color(rgba: [u8; 4]) -> cap_project::Color { mod tests { use super::*; + #[test] + fn playback_shortcut_is_reserved_for_bare_space_outside_text_fields() { + let space = gpui::Keystroke::parse("space").unwrap(); + assert!(is_playback_shortcut(&space, false)); + assert!(!is_playback_shortcut(&space, true)); + for key in [ + "enter", + "s", + "shift-space", + "cmd-space", + "ctrl-space", + "alt-space", + ] { + let keystroke = gpui::Keystroke::parse(key).unwrap(); + assert!(!is_playback_shortcut(&keystroke, false), "{key}"); + } + } + /// `default_editor_preview_resolution()` is asserted to be 1248x702 in the /// Tauri app itself (`lib.rs:192-194`); the render size of a display /// recording follows from it. From 4812fb109786dd5fa521ef9a031ea05e43af341b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:54:03 +0100 Subject: [PATCH 09/10] fix: keep click ripples aligned with frozen cursors --- crates/rendering/src/lib.rs | 139 +++++++++++++++++++++++++++++++----- 1 file changed, 123 insertions(+), 16 deletions(-) diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index d7a1c1799d9..3b7f0dea986 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -2410,14 +2410,15 @@ pub struct ProjectUniforms { pub camera3d_zoom: Option, } -/// Ripples for every mouse-down still inside its animation window, newest -/// last. The centre comes from the smoothed cursor path at the click's own -/// timestamp, so the ring sits under where the cursor was DRAWN rather than -/// where the raw sample was. +fn cursor_time_for_interpolation(time: f32, stop_time: Option) -> f32 { + stop_time.map_or(time, |stop_time| time.min(stop_time)) +} + fn collect_click_ripples( project: &ProjectConfiguration, cursor_events: &CursorEvents, now_ms: f64, + cursor_stop_time: Option, cursor_interp_fn: &dyn Fn(f32) -> Option, ) -> Vec { let ripple = &project.cursor.ripple; @@ -2436,7 +2437,9 @@ fn collect_click_ripples( return None; } - let position = cursor_interp_fn((click.time_ms / 1000.0) as f32)?.position; + let cursor_time = + cursor_time_for_interpolation((click.time_ms / 1000.0) as f32, cursor_stop_time); + let position = cursor_interp_fn(cursor_time)?.position; Some(ClickRipple { position, @@ -3492,17 +3495,10 @@ impl ProjectUniforms { .stop_movement_in_last_seconds .map(|seconds| (total_duration - seconds as f64).max(0.0) as f32); - let cursor_time_for_interp = if let Some(stop_time) = cursor_stop_time { - current_recording_time.min(stop_time) - } else { - current_recording_time - }; - - let prev_cursor_time_for_interp = if let Some(stop_time) = cursor_stop_time { - prev_recording_time.min(stop_time) - } else { - prev_recording_time - }; + let cursor_time_for_interp = + cursor_time_for_interpolation(current_recording_time, cursor_stop_time); + let prev_cursor_time_for_interp = + cursor_time_for_interpolation(prev_recording_time, cursor_stop_time); let cursor_motion_blur = project.cursor.motion_blur.clamp(0.0, 1.0); let screen_motion_blur = project.screen_motion_blur.clamp(0.0, 1.0); @@ -3517,6 +3513,7 @@ impl ProjectUniforms { project, cursor_events, current_recording_time as f64 * 1000.0, + cursor_stop_time, cursor_interp_fn, ); let lookback_t = (cursor_time_for_interp - 0.4).max(0.0); @@ -4502,6 +4499,116 @@ impl ProjectUniforms { mod tests { use super::*; + fn ripple_click(time_ms: f64, down: bool) -> cap_project::CursorClickEvent { + cap_project::CursorClickEvent { + active_modifiers: Vec::new(), + cursor_num: 0, + cursor_id: "cursor".to_owned(), + time_ms, + down, + } + } + + fn ripple_cursor_at(time: f32) -> Option { + Some(InterpolatedCursorPosition { + position: Coord::new(XY::new(f64::from(time), 0.5)), + velocity: XY::new(0.0, 0.0), + cursor_id: "cursor".to_owned(), + }) + } + + #[test] + fn click_ripples_share_cursor_freeze_without_freezing_the_animation() { + let mut project = ProjectConfiguration::default(); + project.cursor.ripple.enabled = true; + project.cursor.ripple.duration = 1.0; + let events = CursorEvents { + clicks: vec![ripple_click(8_500.0, true)], + ..Default::default() + }; + + for (now_ms, expected_progress) in [(8_750.0, 0.25), (9_000.0, 0.5)] { + let ripples = + collect_click_ripples(&project, &events, now_ms, Some(8.0), &ripple_cursor_at); + let displayed = ripple_cursor_at(cursor_time_for_interpolation( + (now_ms / 1000.0) as f32, + Some(8.0), + )) + .unwrap(); + + assert_eq!(ripples.len(), 1); + assert_eq!(ripples[0].position.coord, displayed.position.coord); + assert_eq!(ripples[0].position.coord.x, 8.0); + assert_eq!(ripples[0].progress, expected_progress); + } + } + + #[test] + fn click_ripples_preserve_click_positions_before_the_freeze_or_without_it() { + let mut project = ProjectConfiguration::default(); + project.cursor.ripple.enabled = true; + let events = CursorEvents { + clicks: vec![ripple_click(8_500.0, true)], + ..Default::default() + }; + + for (stop_time, expected_position) in [(None, 8.5), (Some(9.0), 8.5), (Some(0.0), 0.0)] { + let ripples = + collect_click_ripples(&project, &events, 8_750.0, stop_time, &ripple_cursor_at); + + assert_eq!(ripples.len(), 1); + assert_eq!(ripples[0].position.coord.x, expected_position); + } + } + + #[test] + fn click_ripples_exclude_future_expired_and_release_events() { + let mut project = ProjectConfiguration::default(); + project.cursor.ripple.enabled = true; + project.cursor.ripple.duration = 1.0; + let events = CursorEvents { + clicks: vec![ + ripple_click(8_000.0, true), + ripple_click(8_500.0, false), + ripple_click(9_000.0, true), + ripple_click(9_001.0, true), + ], + ..Default::default() + }; + + let ripples = + collect_click_ripples(&project, &events, 9_000.0, Some(8.0), &ripple_cursor_at); + + assert_eq!(ripples.len(), 1); + assert_eq!(ripples[0].progress, 0.0); + assert_eq!(ripples[0].position.coord.x, 8.0); + } + + #[test] + fn click_ripples_respect_visibility_and_the_instance_limit() { + let mut project = ProjectConfiguration::default(); + let events = CursorEvents { + clicks: (0..10) + .map(|index| ripple_click(index as f64, true)) + .collect(), + ..Default::default() + }; + assert!(collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at).is_empty()); + + project.cursor.ripple.enabled = true; + project.cursor.hide = true; + assert!(collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at).is_empty()); + + project.cursor.hide = false; + let ripples = collect_click_ripples(&project, &events, 10.0, None, &ripple_cursor_at); + assert_eq!(ripples.len(), layers::MAX_CLICK_RIPPLES); + assert_eq!(ripples[0].position.coord.x, f64::from(0.004_f32)); + assert_eq!( + ripples.last().unwrap().position.coord.x, + f64::from(0.009_f32) + ); + } + fn render_options(screen_width: u32, screen_height: u32) -> RenderOptions { RenderOptions { screen_size: XY::new(screen_width, screen_height), From c5087f842414a6e14018308da5826c82b03a4012 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:54:03 +0100 Subject: [PATCH 10/10] chore: trim narrative comments from cursor controls --- .../desktop-gpui/src/editor_sidebar/cursor.rs | 50 ------------------- .../src/routes/editor/CursorStylePicker.tsx | 10 ---- 2 files changed, 60 deletions(-) diff --git a/apps/desktop-gpui/src/editor_sidebar/cursor.rs b/apps/desktop-gpui/src/editor_sidebar/cursor.rs index 6bbcb0c8f2a..b7bbd66fc98 100644 --- a/apps/desktop-gpui/src/editor_sidebar/cursor.rs +++ b/apps/desktop-gpui/src/editor_sidebar/cursor.rs @@ -1,23 +1,3 @@ -//! The Cursor tab's style picker and its click-ripple section. -//! -//! The picker is one row of four tiles, each showing a family's arrow drawn -//! from the **real** cursor art in `crates/cursor-info/assets` -- the same -//! SVGs the renderer composites -- so the choice is made by looking at the -//! cursor rather than by reading the word "Windows". The arrow alone is what -//! makes a family recognisable, so the tile shows nothing else. `svg()` keeps -//! only a glyph's alpha and tints it with the element's text colour, which -//! would flatten a two-tone cursor into a silhouette, so each arrow is -//! rasterised with `resvg` into a [`gpui::RenderImage`] and cached per -//! (shape, device-pixel box). -//! -//! The tiles are plain theme surfaces: the assets carry their own -//! black-on-white edge and a soft drop shadow, so they read on a light or a -//! dark tile the way a real cursor reads over a light or dark window. -//! -//! The fourth tile is `Circle`, whose art has no asset: it is the renderer's -//! own touch circle (`crates/rendering/src/layers/cursor.rs` -//! `create_circle_cursor`) restated with gpui primitives. - use cap_cursor_info::{CursorFamily, CursorShape}; use cap_project::CursorType; @@ -27,8 +7,6 @@ use crate::editor_tabs::CursorSlider; const CARD_GAP: f32 = 8.; const TILE_HEIGHT: f32 = 60.; const TILE_RADIUS: f32 = 10.; -/// The arrow's box. Square, and every arrow asset is taller than it is wide, -/// so the fit lands on the height and each family keeps its own width. const ARROW_BOX: f32 = 34.; const CIRCLE_DISC: f32 = 28.; const CARD_GROUP: &str = "cursor-style-card"; @@ -58,9 +36,6 @@ impl CursorCard { } } - /// What clicking the card writes. Always explicit -- the picker never - /// writes `Auto` back, because a card is only ever shown selected on the - /// strength of a family it can name. fn cursor_type(self) -> CursorType { match self { Self::Family(CursorFamily::MacOS) => CursorType::MacOS, @@ -71,8 +46,6 @@ impl CursorCard { } } -/// Host order: the platform's own cursors first, then the other two, then the -/// styled circle. fn cursor_cards() -> [CursorCard; 4] { if cfg!(target_os = "windows") { [ @@ -91,9 +64,6 @@ fn cursor_cards() -> [CursorCard; 4] { } } -/// Which card reads as selected: the explicit type when there is one, and -/// otherwise the family the recording was made with -- or, failing that, this -/// host's -- because that is what `Auto` will actually draw. fn selected_card(cursor_type: &CursorType, recorded: Option) -> CursorCard { if *cursor_type == CursorType::Circle { return CursorCard::Circle; @@ -120,7 +90,6 @@ fn black(alpha: f32) -> Hsla { gpui::hsla(0., 0., 0., alpha) } -/// One cursor shape, rasterised to fit `width` x `height` device pixels. fn rasterize_cursor(shape: CursorShape, width: u32, height: u32) -> Option> { let raw = shape.resolve()?.raw; let tree = resvg::usvg::Tree::from_str(raw, &resvg::usvg::Options::default()).ok()?; @@ -146,8 +115,6 @@ fn rasterize_cursor(shape: CursorShape, width: u32, height: u32) -> Option AnyElement { div() .size(px(CIRCLE_DISC)) @@ -173,9 +140,6 @@ fn circle_art() -> AnyElement { } impl EditorWindow { - /// The ripple colour's hex field, and the device scale the previews are - /// rasterised for. Both need a `&mut Window`, which the sidebar's render - /// chain does not carry, so they are settled once a frame from `render`. pub(crate) fn prepare_cursor_fields(&mut self, window: &mut Window, cx: &mut Context) { if self.sidebar.tab != SidebarTab::Cursor { return; @@ -219,10 +183,6 @@ impl EditorWindow { .into_any_element() } - /// The tile: the family's arrow (or the touch circle) centred on a plain - /// surface. `RadioCards`' grammar for the states -- `border-gray-3 - /// bg-gray-2`, `hover:border-gray-5`, and `border-blue-8 bg-blue-3/40` - /// plus a 1px ring (so a 2px edge) when checked. fn render_cursor_tile(&self, card: CursorCard, selected: bool, recorded: bool) -> AnyElement { let theme = self.theme; let art = match card { @@ -305,7 +265,6 @@ impl EditorWindow { .child(card.label()), ) .on_click(cx.listener(move |this, _, window, cx| { - // `CursorType` is not `Copy`, and the listener is an `Fn`. let cursor_type = cursor_type.clone(); this.edit_project("cursor-type", window, cx, move |project| { if *project.cursor.cursor_type() == cursor_type { @@ -318,7 +277,6 @@ impl EditorWindow { .into_any_element() } - /// `grid grid-cols-4 gap-2`: one row, the four cards sharing the width. pub(crate) fn render_cursor_style_picker(&self, cx: &mut Context) -> AnyElement { let selected = self.selected_cursor_card(); let recorded = self.recorded_cursor_family; @@ -335,8 +293,6 @@ impl EditorWindow { .into_any_element() } - /// "Click Ripple" and, once it is on, the ring's colour and its three - /// shape sliders. pub(crate) fn render_cursor_ripple(&self, cx: &mut Context) -> AnyElement { let theme = self.theme; let ripple = &self.project.cursor.ripple; @@ -418,8 +374,6 @@ mod tests { } } - /// Every card writes a type that resolves back to the card it was drawn - /// on, which is what keeps the selected ring on the card just clicked. #[test] fn every_card_round_trips_through_its_type() { for card in cursor_cards() { @@ -435,7 +389,6 @@ mod tests { selected_card(&CursorType::Auto, Some(CursorFamily::MacOSTahoe)), CursorCard::Family(CursorFamily::MacOSTahoe) ); - // The legacy value renders exactly as `Auto` does. assert_eq!( selected_card(&CursorType::Pointer, Some(CursorFamily::Windows)), CursorCard::Family(CursorFamily::Windows) @@ -446,9 +399,6 @@ mod tests { ); } - /// Every family's arrow resolves and rasterises at the tile's box (at 1x - /// and 2x) -- a card with a missing asset would silently draw an empty - /// tile. #[test] fn every_arrow_rasterises() { for family in [ diff --git a/apps/desktop/src/routes/editor/CursorStylePicker.tsx b/apps/desktop/src/routes/editor/CursorStylePicker.tsx index 90fef4d8b4e..b033d2387a5 100644 --- a/apps/desktop/src/routes/editor/CursorStylePicker.tsx +++ b/apps/desktop/src/routes/editor/CursorStylePicker.tsx @@ -16,8 +16,6 @@ import { Field, Slider } from "./ui"; export type CursorFamily = "macos" | "tahoe" | "windows"; type CursorStyle = CursorFamily | "circle"; -// One arrow per family: the arrow is what makes a cursor family recognisable -// at a glance, and the tile only has to say "this one", not exhibit the set. const CURSOR_FAMILIES = { macos: { label: "macOS", arrow: macArrow }, tahoe: { label: "macOS Tahoe", arrow: tahoeArrow }, @@ -70,16 +68,11 @@ function cursorStyleOrder(): CursorStyle[] { } function CursorArrow(props: { svg: string }) { - // The assets carry their own black-on-white edge and a soft drop shadow, - // so they read on the plain tile in both themes, the same way a real - // cursor reads over a light or dark window. return (
); } -// The renderer's touch circle (`create_circle_cursor`): a translucent disc -// with a dark outer ring, a light inner ring and a faint shadow. function CircleCursor() { return (
@@ -123,9 +116,6 @@ export function CursorStylePicker() { const recorded = createMemo(() => recordedCursorFamily(meta())); - // `auto` draws the recording's own family (or, without shape info, this - // host's), so that is the tile that reads as selected. Clicking always - // writes an explicit family; `auto` is never written back. const selected = createMemo(() => { const type = project.cursor.type; if (type === "circle" || isExplicitCursorFamily(type)) return type;