From 8dedfa8d0e8ec4ea760135d46bc33ca71a0b7874 Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Fri, 3 Oct 2025 22:38:30 +0100 Subject: [PATCH 1/7] feat(core): enable click animation Adds a new data channel command which enables/disables an animation effect when a controller clicks. When the click animation is enabled the cursor loses the ability to perform clicks and scroll which would mean it would take control. --- core/resources/click_texture.png | Bin 0 -> 785 bytes core/src/graphics/click_animation.rs | 715 ++++++++++++++++++++++++++ core/src/graphics/cursor.rs | 118 +---- core/src/graphics/graphics_context.rs | 39 +- core/src/graphics/point.rs | 111 ++++ core/src/graphics/shader.wgsl | 33 ++ core/src/input/mouse.rs | 105 ++-- core/src/lib.rs | 22 + core/src/room_service.rs | 13 + core/tests/src/events.rs | 6 + core/tests/src/main.rs | 6 + core/tests/src/remote_cursor.rs | 191 ++++++- core/tests/src/screenshare_client.rs | 11 +- 13 files changed, 1222 insertions(+), 148 deletions(-) create mode 100644 core/resources/click_texture.png create mode 100644 core/src/graphics/click_animation.rs create mode 100644 core/src/graphics/point.rs diff --git a/core/resources/click_texture.png b/core/resources/click_texture.png new file mode 100644 index 0000000000000000000000000000000000000000..07abb6247d9c310a8cd747c479605640c8a961bc GIT binary patch literal 785 zcmV+s1Md8ZP)*iuPBewo`3%g8DGzGC81EXeyqhs3H9=y7hrvk$_*= zQ>8N4t*viq;XN6V*_>KQ&EkQH7~#^qt)SG zxfJ%LF`24HlV~>xIPGYja0X3}c7t;^P7f?+Y+!dJv*UA_cr27LCv`L}-@VhFYK1{g z_>)z|CoLw^zsP0ty{UL5^C4C3*A8{b3hbI+Ok#$xSG56Z{nR}Setv(>X#U&sQPFGQ3GD@pMJ67q$3>?VOkOaSO#Nd)bM69cH-`cv?T+>s2*Plg33=%r1n^ zP4HUf@$MF2^NeJZaeKMjD&kM}ivfa|9>D1rj-8GsW?a*22bve4Ajy$kEr!CJ35JB= z$a~8`e{gW?#xxov`_|nN6+-%UT94LlOT<3pt_Pt}d^)3Ez$w4uJTWnA=O(4cUwI16u{7mH2 P00000NkvXXu0mjfI8I%^ literal 0 HcmV?d00001 diff --git a/core/src/graphics/click_animation.rs b/core/src/graphics/click_animation.rs new file mode 100644 index 0000000..b08ec59 --- /dev/null +++ b/core/src/graphics/click_animation.rs @@ -0,0 +1,715 @@ +//! Click animation rendering system for overlay graphics. +//! +//! This module provides a GPU-accelerated click animation rendering system using wgpu. +//! It supports multiple click animations with individual textures, transforms, and positions. +//! The system uses a shared transform buffer with dynamic offsets for efficient +//! rendering of multiple click animations. + +use crate::utils::geometry::{Extent, Position}; +use std::{collections::VecDeque, fs::File, io::Read}; +use wgpu::util::DeviceExt; + +use super::{ + create_texture, + point::{Point, TransformMatrix}, + OverlayError, Texture, Vertex, +}; + +/// Maximum number of click animations that can be rendered simultaneously +const MAX_ANIMATIONS: usize = 30; + +/// Base horizontal offset for click animation positioning (as a fraction of screen space) +const BASE_OFFSET_X: f32 = 0.007; +/// Base vertical offset for click animation positioning (as a fraction of screen space) +const BASE_OFFSET_Y: f32 = 0.015; + +/// Represents a single click animation with its texture, geometry, and position data. +/// +/// Each click animation maintains its own vertex and index buffers for geometry, +/// a texture for appearance, and position information for rendering. +/// The animation uses dynamic offsets into shared transform and radius buffers. +#[derive(Debug)] +pub struct ClickAnimation { + /// The click animation's texture (image) + texture: Texture, + /// GPU buffer containing vertex data for the animation quad + vertex_buffer: wgpu::Buffer, + /// GPU buffer containing index data for the animation quad + index_buffer: wgpu::Buffer, + /// Dynamic offset into the shared transform buffer + transform_offset: wgpu::DynamicOffset, + /// Position and transformation data + position: Point, + /// Dynamic offset into the shared radius buffer + radius_offset: wgpu::DynamicOffset, + /// Time when the animation was enabled, None if disabled + enabled_instant: Option, +} + +impl ClickAnimation { + /// Updates the GPU transform buffer with this animation's current position. + /// + /// # Arguments + /// * `queue` - wgpu queue for uploading data to GPU + /// * `transforms_buffer` - Shared transform buffer + /// + /// This method uploads the animation's transformation matrix to the GPU + /// at the appropriate offset in the shared buffer. + pub fn update_transform_buffer(&self, queue: &wgpu::Queue, transforms_buffer: &wgpu::Buffer) { + queue.write_buffer( + &transforms_buffer, + self.transform_offset as wgpu::BufferAddress, + bytemuck::cast_slice(&[self.position.get_transform_matrix()]), + ); + } + + /// Updates the GPU radius buffer for this animation's current radius value. + /// + /// # Arguments + /// * `queue` - wgpu queue for uploading data to GPU + /// * `radius_buffer` - Shared radius buffer + /// * `radius` - Current radius value for the animation + pub fn update_radius(&self, queue: &wgpu::Queue, radius_buffer: &wgpu::Buffer, radius: f32) { + queue.write_buffer( + &radius_buffer, + self.radius_offset as wgpu::BufferAddress, + bytemuck::cast_slice(&[radius]), + ); + } + + /// Enables the click animation at the specified position. + /// + /// # Arguments + /// * `position` - Screen position where the animation should appear + /// * `queue` - wgpu queue for uploading data to GPU + /// * `transforms_buffer` - Shared transform buffer + /// * `radius_buffer` - Shared radius buffer + /// + /// This method initializes the animation with a starting radius and position, + /// and records the current time for animation timing. + pub fn enable( + &mut self, + position: Position, + queue: &wgpu::Queue, + transforms_buffer: &wgpu::Buffer, + radius_buffer: &wgpu::Buffer, + ) { + self.position + .set_position(position.x as f32, position.y as f32); + self.update_transform_buffer(queue, &transforms_buffer); + self.update_radius(queue, &radius_buffer, 0.1); + self.enabled_instant = Some(std::time::Instant::now()); + } + + /// Disables the click animation by moving it off-screen. + /// + /// # Arguments + /// * `queue` - wgpu queue for uploading data to GPU + /// * `transforms_buffer` - Shared transform buffer + /// + /// This method hides the animation by positioning it off-screen and + /// clears the enabled timestamp. + pub fn disable(&mut self, queue: &wgpu::Queue, transforms_buffer: &wgpu::Buffer) { + self.position.set_position(-100.0, -100.0); + self.update_transform_buffer(queue, &transforms_buffer); + self.enabled_instant = None; + } + + /// Renders this click animation using the provided render pass. + /// + /// # Arguments + /// * `render_pass` - Active wgpu render pass for drawing + /// * `queue` - wgpu queue for uploading data to GPU + /// * `radius_buffer` - Shared radius buffer + /// * `transforms_bind_group` - Bind group for transformation matrices + /// * `radius_bind_group` - Bind group for radius values + /// + /// This method handles the animation timing, updates the radius based on elapsed time, + /// and renders the animation to the current render target. The animation automatically + /// disables itself after 1s. + pub fn draw( + &mut self, + render_pass: &mut wgpu::RenderPass, + queue: &wgpu::Queue, + radius_buffer: &wgpu::Buffer, + transforms_bind_group: &wgpu::BindGroup, + radius_bind_group: &wgpu::BindGroup, + ) { + if self.enabled_instant.is_none() { + return; + } + let enabled_instant = self.enabled_instant.unwrap(); + let radius_start = 0.1; + let elapsed = enabled_instant.elapsed().as_millis(); + let time_offset = 300; + if elapsed > time_offset { + let radius = radius_start + (elapsed - time_offset) as f32 / 2333.0; + self.update_radius(queue, radius_buffer, radius); + } + if elapsed > 1000 { + self.disable(queue, &radius_buffer); + } + render_pass.set_bind_group(0, &self.texture.bind_group, &[]); + render_pass.set_bind_group(1, transforms_bind_group, &[self.transform_offset]); + render_pass.set_bind_group(2, radius_bind_group, &[self.radius_offset]); + render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..6, 0, 0..1); + } +} + +/// Main click animation rendering system that manages multiple animations. +/// +/// This renderer creates and manages the GPU resources needed for click animation rendering, +/// including shaders, pipelines, and shared buffers. It uses shared transform and radius +/// buffers with dynamic offsets to efficiently handle multiple animations. +/// +/// # Design Notes +/// +/// Due to compatibility issues with development Windows VMs, this implementation +/// uses shared buffers with dynamic offsets rather than separate buffers for each animation. +/// A channel is used to safely communicate animation enable requests from other threads +/// to the render thread. +#[derive(Debug)] +pub struct ClickAnimationRenderer { + /// GPU render pipeline for click animation rendering + pub render_pipeline: wgpu::RenderPipeline, + /// Bind group layout for animation textures + pub texture_bind_group_layout: wgpu::BindGroupLayout, + /// Bind group layout for transformation matrices + pub transform_bind_group_layout: wgpu::BindGroupLayout, + /// Shared buffer containing all animation transform matrices + pub transforms_buffer: wgpu::Buffer, + /// Size of each entry in the transform buffer (including alignment) + pub transforms_buffer_entry_offset: wgpu::BufferAddress, + /// Bind group for accessing the transform buffer + pub transforms_bind_group: wgpu::BindGroup, + /// Bind group layout for animation radius values + pub radius_bind_group_layout: wgpu::BindGroupLayout, + /// Shared buffer containing all animation radius values + pub radius_buffer: wgpu::Buffer, + /// Size of each entry in the radius buffer (including alignment) + pub radius_buffer_entry_offset: wgpu::BufferAddress, + /// Bind group for accessing the radius buffer + pub radius_bind_group: wgpu::BindGroup, + /// Sender for communicating animation enable requests to the render thread + pub clik_animation_position_sender: std::sync::mpsc::Sender, + /// Receiver for animation enable requests (only accessed from render thread) + pub clik_animation_position_receiver: std::sync::mpsc::Receiver, + /// Array of all click animation instances + pub click_animations: Vec, + /// Queue of available (inactive) animation slots + pub available_slots: VecDeque, + /// Queue of currently used (active) animation slots + pub used_slots: VecDeque, +} + +struct ClickAnimationCreateData<'a> { + texture_path: String, + scale: f64, + device: &'a wgpu::Device, + queue: &'a wgpu::Queue, + window_size: Extent, + texture_bind_group_layout: &'a wgpu::BindGroupLayout, + transforms_buffer_entry_offset: wgpu::BufferAddress, + transforms_buffer: &'a wgpu::Buffer, + radius_buffer_entry_offset: wgpu::BufferAddress, + radius_buffer: &'a wgpu::Buffer, + animations_created: u32, +} + +impl ClickAnimationRenderer { + /// Creates a new click animation renderer with all necessary GPU resources. + /// + /// # Arguments + /// * `device` - wgpu device for creating GPU resources + /// * `queue` - wgpu queue for uploading initial data + /// * `texture_format` - Format of the render target texture + /// * `texture_path` - Path to the texture resource directory + /// * `window_size` - Size of the rendering window + /// * `scale` - Display scale factor + /// + /// # Returns + /// A fully initialized animation renderer ready to render click animations, + /// or an error if initialization fails. + /// + /// This method sets up: + /// - Bind group layouts for textures, transforms, and radius values + /// - Shared transform and radius buffers with proper alignment + /// - Render pipeline with vertex and fragment shaders + /// - Pre-allocated pool of click animation instances + /// - Channel for thread-safe animation enable requests + pub fn create( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture_format: wgpu::TextureFormat, + texture_path: &String, + window_size: Extent, + scale: f64, + ) -> Result { + // Create bind group layout for click animation textures + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Shared Click Animation Texture BGL"), + entries: &[ + // Texture + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // Sampler + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + /* + * Because of an issue in our dev windows vm when using a separate transform + * buffer for each animation, we are using a single transform buffer for all animations + * with dynamic offsets. + */ + + // Calculate proper buffer alignment for transform matrices + let device_limits = device.limits(); + let buffer_uniform_alignment = + device_limits.min_uniform_buffer_offset_alignment as wgpu::BufferAddress; + let transform_buffer_size = std::mem::size_of::() as wgpu::BufferAddress; + let aligned_buffer_size = (transform_buffer_size + buffer_uniform_alignment - 1) + & !(buffer_uniform_alignment - 1); + + // Create bind group layout for transformation matrices + let transform_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Transform BGL"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: std::num::NonZero::new(transform_buffer_size), + }, + count: None, + }], + }); + + // Create shared transform buffer for all animations + let transforms_buffer_size = aligned_buffer_size * MAX_ANIMATIONS as wgpu::BufferAddress; + let transforms_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Transforms Buffer"), + size: transforms_buffer_size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create bind group for the transform buffer + let transform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Transforms Buffer Bind Group"), + layout: &transform_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &transforms_buffer, + offset: 0, + size: std::num::NonZero::new(transform_buffer_size), + }), + }], + }); + + // Create radius uniform buffer for click animation, the radius will change over time + // for the animation + let radius_buffer_size = std::mem::size_of::() as wgpu::BufferAddress; + let aligned_radius_buffer_size = + (radius_buffer_size + buffer_uniform_alignment - 1) & !(buffer_uniform_alignment - 1); + + let radius_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Radius BGL"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: std::num::NonZero::new(radius_buffer_size), + }, + count: None, + }], + }); + log::info!("aligned_radius_buffer_size: {}", aligned_radius_buffer_size); + let radius_whole_buffer_size = + aligned_radius_buffer_size * MAX_ANIMATIONS as wgpu::BufferAddress; + let radius_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Radius Buffer"), + size: radius_whole_buffer_size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let radius_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Radius Buffer Bind Group"), + layout: &radius_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &radius_buffer, + offset: 0, + size: std::num::NonZero::new(radius_buffer_size), + }), + }], + }); + + // Load shader and create render pipeline + let shader = device.create_shader_module(wgpu::include_wgsl!("shader.wgsl")); + let render_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Render Pipeline Click Animation"), + bind_group_layouts: &[ + &texture_bind_group_layout, + &transform_bind_group_layout, + &radius_bind_group_layout, + ], + push_constant_ranges: &[], + }); + + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Render Pipeline Click Animation"), + layout: Some(&render_pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_click_animation_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + ], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_click_animation_main"), + targets: &[Some(wgpu::ColorTargetState { + format: texture_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }); + + // Create the click animations + let mut click_animations = Vec::new(); + let mut available_slots = VecDeque::new(); + for i in 0..MAX_ANIMATIONS { + let click_animation = Self::create_click_animation(ClickAnimationCreateData { + texture_path: texture_path.clone(), + scale, + device, + queue, + window_size, + texture_bind_group_layout: &texture_bind_group_layout, + transforms_buffer_entry_offset: aligned_buffer_size, + transforms_buffer: &transforms_buffer, + radius_buffer_entry_offset: aligned_radius_buffer_size, + radius_buffer: &radius_buffer, + animations_created: i as u32, + })?; + + click_animations.push(click_animation); + available_slots.push_back(i); + } + + let (sender, receiver) = std::sync::mpsc::channel(); + Ok(Self { + render_pipeline, + texture_bind_group_layout, + transform_bind_group_layout, + transforms_buffer, + transforms_buffer_entry_offset: aligned_buffer_size, + transforms_bind_group: transform_bind_group, + radius_bind_group_layout, + radius_buffer, + radius_buffer_entry_offset: aligned_radius_buffer_size, + radius_bind_group, + clik_animation_position_sender: sender, + clik_animation_position_receiver: receiver, + click_animations, + available_slots, + used_slots: VecDeque::new(), + }) + } + + /// Creates a new click animation instance with the specified properties. + /// + /// # Arguments + /// * `data` - Configuration data containing all necessary parameters for animation creation + /// + /// # Returns + /// A new `ClickAnimation` instance ready for rendering, or an error if creation fails. + /// + /// # Errors + /// Returns `OverlayError::TextureCreationError` if: + /// - The texture file cannot be opened or read + /// - Texture creation fails + /// + /// The animation is automatically positioned off-screen and disabled by default. + /// Its transform matrix and radius are uploaded to the GPU at the appropriate offsets. + fn create_click_animation( + data: ClickAnimationCreateData, + ) -> Result { + let resource_path = format!("{}/click_texture.png", data.texture_path); + log::debug!("create_click_animation: resource path: {resource_path:?}"); + + let mut file = match File::open(&resource_path) { + Ok(file) => file, + Err(_) => { + log::error!("create_click_animation: failed to open file: click_texture.png"); + return Err(OverlayError::TextureCreationError); + } + }; + let mut image_data = Vec::new(); + let res = file.read_to_end(&mut image_data); + if res.is_err() { + log::error!("create_click_animation: failed to read file: click_texture.png"); + return Err(OverlayError::TextureCreationError); + } + + // Create texture from image file + let texture = create_texture( + data.device, + data.queue, + &image_data, + &data.texture_bind_group_layout, + )?; + + // Create vertex and index buffers for animation geometry + let (vertex_buffer, index_buffer) = Self::create_animation_vertex_buffer( + data.device, + &texture, + data.scale, + data.window_size, + ); + + // Calculate offset into shared transform buffer + let transform_offset = + (data.animations_created as wgpu::BufferAddress) * data.transforms_buffer_entry_offset; + + // Initialize animation position with base offsets + let point = Point::new( + 0.0, + 0.0, + BASE_OFFSET_X * (data.scale as f32), + BASE_OFFSET_Y * (data.scale as f32), + ); + + // Upload initial transform matrix to GPU + data.queue.write_buffer( + &data.transforms_buffer, + transform_offset, + bytemuck::cast_slice(&[point.get_transform_matrix()]), + ); + + let radius_offset = + (data.animations_created as wgpu::BufferAddress) * data.radius_buffer_entry_offset; + data.queue.write_buffer( + &data.radius_buffer, + radius_offset, + bytemuck::cast_slice(&[0.0f32]), + ); + + Ok(ClickAnimation { + texture, + vertex_buffer, + index_buffer, + transform_offset: transform_offset as wgpu::DynamicOffset, + position: point, + radius_offset: radius_offset as wgpu::DynamicOffset, + enabled_instant: None, + }) + } + + /// Creates vertex and index buffers for a click animation quad. + /// + /// # Arguments + /// * `device` - wgpu device for creating buffers + /// * `texture` - Animation texture containing size information + /// * `scale` - Scale factor for animation size + /// * `window_size` - Window dimensions for proper aspect ratio + /// + /// # Returns + /// A tuple containing (vertex_buffer, index_buffer) for the animation quad. + /// + /// This method creates a quad that maintains the original texture aspect ratio + /// while scaling appropriately for the target window size. The quad is positioned + /// at the top-left of normalized device coordinates and sized according to the + /// texture dimensions and scale factor. + fn create_animation_vertex_buffer( + device: &wgpu::Device, + texture: &Texture, + scale: f64, + window_size: Extent, + ) -> (wgpu::Buffer, wgpu::Buffer) { + // Calculate animation size in clip space, maintaining aspect ratio + let clip_extent = Extent { + width: (texture.extent.width / window_size.width) * scale * 1.5, + height: (texture.extent.height / window_size.height) * scale * 1.5, + }; + + // Create quad vertices with texture coordinates + let vertices = vec![ + Vertex { + position: [-1.0, 1.0], + texture_coords: [0.0, 0.0], + }, + Vertex { + position: [-1.0, 1.0 - clip_extent.height as f32], + texture_coords: [0.0, 1.0], + }, + Vertex { + position: [ + -1.0 + clip_extent.width as f32, + 1.0 - clip_extent.height as f32, + ], + texture_coords: [1.0, 1.0], + }, + Vertex { + position: [-1.0 + clip_extent.width as f32, 1.0], + texture_coords: [1.0, 0.0], + }, + ]; + + // Define triangle indices for the quad (two triangles) + let indices = vec![0, 1, 2, 0, 2, 3]; + + // Create GPU buffers + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Vertex Buffer"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Index Buffer"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }); + + (vertex_buffer, index_buffer) + } + + /// Requests to enable a click animation at the specified position. + /// + /// # Arguments + /// * `position` - Screen position where the animation should appear + /// + /// This method sends the position through a channel to the render thread, + /// where it will be processed on the next draw call. This allows animations + /// to be triggered from any thread safely. + pub fn enable_click_animation(&mut self, position: Position) { + if let Err(e) = self.clik_animation_position_sender.send(position) { + log::error!("enable_click_animation: error sending position: {e:?}"); + } + } + + /// Draws all active click animations to the provided render pass. + /// + /// # Arguments + /// * `render_pass` - Active wgpu render pass for drawing + /// * `queue` - wgpu queue for uploading data to GPU + /// + /// This method: + /// 1. Processes any pending animation enable requests from the channel + /// 2. Allocates slots for new animations from the available pool + /// 3. Renders all active animations + /// 4. Reclaims slots from completed animations back to the available pool + /// + /// The method automatically manages the lifecycle of animations, returning + /// them to the available pool once they complete. + pub fn draw(&mut self, render_pass: &mut wgpu::RenderPass, queue: &wgpu::Queue) { + // Drain click animation enable requests. + while let Ok(position) = self.clik_animation_position_receiver.try_recv() { + if self.available_slots.is_empty() { + log::warn!("enable_click_animation: available_slots is empty"); + break; + } + + let slot = self.available_slots.pop_front().unwrap(); + self.used_slots.push_back(slot); + + self.click_animations[slot].enable( + position, + queue, + &self.transforms_buffer, + &self.radius_buffer, + ); + } + + if self.used_slots.is_empty() { + return; + } + + render_pass.set_pipeline(&self.render_pipeline); + + for slot in self.used_slots.iter() { + self.click_animations[*slot].draw( + render_pass, + queue, + &self.radius_buffer, + &self.transforms_bind_group, + &self.radius_bind_group, + ); + } + + loop { + let front = self.used_slots.front(); + if front.is_none() { + break; + } + + let slot = front.unwrap().clone(); + if self.click_animations[slot].enabled_instant.is_none() { + let front = self.used_slots.pop_front().unwrap(); + self.available_slots.push_back(front); + } else { + break; + } + } + } +} diff --git a/core/src/graphics/cursor.rs b/core/src/graphics/cursor.rs index b03e9fc..af4633c 100644 --- a/core/src/graphics/cursor.rs +++ b/core/src/graphics/cursor.rs @@ -8,7 +8,11 @@ use crate::utils::geometry::Extent; use wgpu::util::DeviceExt; -use super::{create_texture, GraphicsContext, OverlayError, Texture, Vertex}; +use super::{ + create_texture, + point::{Point, TransformMatrix}, + GraphicsContext, OverlayError, Texture, Vertex, +}; /// Maximum number of cursors that can be rendered simultaneously const MAX_CURSORS: u32 = 100; @@ -17,118 +21,6 @@ const BASE_OFFSET_X: f32 = 0.001; /// Base vertical offset for cursor positioning (as a fraction of screen space) const BASE_OFFSET_Y: f32 = 0.002; -/// A 4x4 transformation matrix for GPU vertex transformations. -/// -/// This matrix is used to transform cursor vertices in the shader, -/// primarily for positioning cursors at specific screen coordinates. -#[repr(C)] -#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct TransformMatrix { - pub matrix: [[f32; 4]; 4], -} - -/// Uniform buffer data structure containing a transformation matrix. -/// -/// This struct is uploaded to the GPU as a uniform buffer to provide -/// transformation data to the vertex shader for cursor positioning. -#[repr(C)] -#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct TranslationUniform { - transform: TransformMatrix, -} - -impl TranslationUniform { - /// Creates a new translation uniform with an identity transformation matrix. - /// - /// The identity matrix means no transformation is applied initially. - fn new() -> Self { - Self { - transform: TransformMatrix { - matrix: [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ], - }, - } - } - - /// Sets the translation component of the transformation matrix. - /// - /// # Arguments - /// * `x` - Horizontal translation in normalized device coordinates (-1.0 to 1.0) - /// * `y` - Vertical translation in normalized device coordinates (-1.0 to 1.0) - /// - /// # Note - /// The coordinates are multiplied by 2.0 because the input is expected to be - /// in the range 0.0-1.0, but NDC space ranges from -1.0 to 1.0. - /// Y is negated to match screen coordinate conventions. - fn set_translation(&mut self, x: f32, y: f32) { - // We need to multiply by 2.0 because the cursor position is in the range of -1.0 to 1.0 - self.transform.matrix[3][0] = x * 2.0; - self.transform.matrix[3][1] = -y * 2.0; - } -} - -/// Represents a point in 2D space with position and offset information. -/// -/// This struct manages cursor positioning with both absolute coordinates -/// and rendering offsets. The transform matrix is automatically updated -/// when the position changes. -#[derive(Debug)] -struct Point { - /// Absolute X coordinate - x: f32, - /// Absolute Y coordinate - y: f32, - /// Horizontal rendering offset - offset_x: f32, - /// Vertical rendering offset - offset_y: f32, - /// GPU transformation matrix for this point - transform_matrix: TranslationUniform, -} - -impl Point { - /// Creates a new point with the specified position and offsets. - /// - /// # Arguments - /// * `x` - Initial X coordinate - /// * `y` - Initial Y coordinate - /// * `offset_x` - Horizontal rendering offset - /// * `offset_y` - Vertical rendering offset - fn new(x: f32, y: f32, offset_x: f32, offset_y: f32) -> Self { - Self { - x, - y, - offset_x, - offset_y, - transform_matrix: TranslationUniform::new(), - } - } - - /// Returns the current transformation matrix for GPU upload. - fn get_transform_matrix(&self) -> TransformMatrix { - self.transform_matrix.transform - } - - /// Updates the point's position and recalculates the transformation matrix. - /// - /// # Arguments - /// * `x` - New X coordinate - /// * `y` - New Y coordinate - /// - /// The transformation matrix is updated to position the cursor at the - /// specified coordinates, accounting for the configured offsets. - fn set_position(&mut self, x: f32, y: f32) { - self.x = x; - self.y = y; - self.transform_matrix - .set_translation(x - self.offset_x, y - self.offset_y); - } -} - /// Represents a single cursor with its texture, geometry, and position data. /// /// Each cursor maintains its own vertex and index buffers for geometry, diff --git a/core/src/graphics/graphics_context.rs b/core/src/graphics/graphics_context.rs index 2a09da9..b37f706 100644 --- a/core/src/graphics/graphics_context.rs +++ b/core/src/graphics/graphics_context.rs @@ -4,8 +4,8 @@ //! such as cursors and markers on top of shared screen content. It uses wgpu for //! hardware-accelerated rendering with proper alpha blending and transparent window support. -use crate::input::mouse::CursorController; use crate::utils::geometry::Extent; +use crate::{input::mouse::CursorController, utils::geometry::Position}; use image::GenericImageView; use log::error; use std::sync::Arc; @@ -23,6 +23,13 @@ use marker::MarkerRenderer; pub mod cursor; use cursor::{Cursor, CursorsRenderer}; +#[path = "click_animation.rs"] +pub mod click_animation; +use click_animation::ClickAnimationRenderer; + +#[path = "point.rs"] +pub mod point; + /// Errors that can occur during overlay graphics operations. #[derive(Error, Debug)] pub enum OverlayError { @@ -128,6 +135,9 @@ pub struct GraphicsContext<'a> { /// Renderer for corner markers indicating overlay boundaries marker_renderer: MarkerRenderer, + + /// Renderer for click animations + click_animation_renderer: ClickAnimationRenderer, } impl<'a> GraphicsContext<'a> { @@ -275,6 +285,18 @@ impl<'a> GraphicsContext<'a> { scale, )?; + let click_animation_renderer = ClickAnimationRenderer::create( + &device, + &queue, + surface_config.format, + &texture_path, + Extent { + width: size.width as f64, + height: size.height as f64, + }, + scale, + )?; + Ok(Self { surface, device, @@ -284,6 +306,7 @@ impl<'a> GraphicsContext<'a> { #[cfg(target_os = "windows")] _direct_composition: direct_composition, marker_renderer, + click_animation_renderer, }) } @@ -345,7 +368,7 @@ impl<'a> GraphicsContext<'a> { /// If frame acquisition fails (e.g., surface lost), the method logs the error /// and returns early without crashing. This provides resilience against /// temporary graphics driver issues or window state changes. - pub fn draw(&self, cursor_controller: &CursorController) { + pub fn draw(&mut self, cursor_controller: &CursorController) { let output = match self.surface.get_current_texture() { Ok(output) => output, Err(e) => { @@ -385,6 +408,8 @@ impl<'a> GraphicsContext<'a> { cursor_controller.draw(&mut render_pass, self); self.marker_renderer.draw(&mut render_pass); + self.click_animation_renderer + .draw(&mut render_pass, &self.queue); drop(render_pass); @@ -403,6 +428,16 @@ impl<'a> GraphicsContext<'a> { pub fn window(&self) -> &Window { &self.window } + + /// Requests to enable a click animation at the specified position. + /// + /// # Arguments + /// * `position` - Screen position where the animation should appear + pub fn enable_click_animation(&mut self, position: Position) { + log::debug!("GraphicsContext::enable_click_animation: {position:?}"); + self.click_animation_renderer + .enable_click_animation(position); + } } /// Creates a GPU texture from an image file for overlay rendering. diff --git a/core/src/graphics/point.rs b/core/src/graphics/point.rs new file mode 100644 index 0000000..8cc45e2 --- /dev/null +++ b/core/src/graphics/point.rs @@ -0,0 +1,111 @@ +/// A 4x4 transformation matrix for GPU vertex transformations. +/// +/// This matrix is used to transform cursor vertices in the shader, +/// primarily for positioning cursors at specific screen coordinates. +#[repr(C)] +#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct TransformMatrix { + pub matrix: [[f32; 4]; 4], +} + +/// Uniform buffer data structure containing a transformation matrix. +/// +/// This struct is uploaded to the GPU as a uniform buffer to provide +/// transformation data to the vertex shader for cursor positioning. +#[repr(C)] +#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct TranslationUniform { + transform: TransformMatrix, +} + +impl TranslationUniform { + /// Creates a new translation uniform with an identity transformation matrix. + /// + /// The identity matrix means no transformation is applied initially. + fn new() -> Self { + Self { + transform: TransformMatrix { + matrix: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }, + } + } + + /// Sets the translation component of the transformation matrix. + /// + /// # Arguments + /// * `x` - Horizontal translation in normalized device coordinates (-1.0 to 1.0) + /// * `y` - Vertical translation in normalized device coordinates (-1.0 to 1.0) + /// + /// # Note + /// The coordinates are multiplied by 2.0 because the input is expected to be + /// in the range 0.0-1.0, but NDC space ranges from -1.0 to 1.0. + /// Y is negated to match screen coordinate conventions. + fn set_translation(&mut self, x: f32, y: f32) { + // We need to multiply by 2.0 because the cursor position is in the range of -1.0 to 1.0 + self.transform.matrix[3][0] = x * 2.0; + self.transform.matrix[3][1] = -y * 2.0; + } +} + +/// Represents a point in 2D space with position and offset information. +/// +/// This struct manages cursor positioning with both absolute coordinates +/// and rendering offsets. The transform matrix is automatically updated +/// when the position changes. +#[derive(Debug)] +pub struct Point { + /// Absolute X coordinate + x: f32, + /// Absolute Y coordinate + y: f32, + /// Horizontal rendering offset + offset_x: f32, + /// Vertical rendering offset + offset_y: f32, + /// GPU transformation matrix for this point + transform_matrix: TranslationUniform, +} + +impl Point { + /// Creates a new point with the specified position and offsets. + /// + /// # Arguments + /// * `x` - Initial X coordinate + /// * `y` - Initial Y coordinate + /// * `offset_x` - Horizontal rendering offset + /// * `offset_y` - Vertical rendering offset + pub fn new(x: f32, y: f32, offset_x: f32, offset_y: f32) -> Self { + Self { + x, + y, + offset_x, + offset_y, + transform_matrix: TranslationUniform::new(), + } + } + + /// Returns the current transformation matrix for GPU upload. + pub fn get_transform_matrix(&self) -> TransformMatrix { + self.transform_matrix.transform + } + + /// Updates the point's position and recalculates the transformation matrix. + /// + /// # Arguments + /// * `x` - New X coordinate + /// * `y` - New Y coordinate + /// + /// The transformation matrix is updated to position the cursor at the + /// specified coordinates, accounting for the configured offsets. + pub fn set_position(&mut self, x: f32, y: f32) { + self.x = x; + self.y = y; + self.transform_matrix + .set_translation(x - self.offset_x, y - self.offset_y); + } +} diff --git a/core/src/graphics/shader.wgsl b/core/src/graphics/shader.wgsl index 8794e1d..faccbb6 100644 --- a/core/src/graphics/shader.wgsl +++ b/core/src/graphics/shader.wgsl @@ -44,4 +44,37 @@ fn vs_lines_main( out.texture_coords = model.texture_coords; out.clip_position = vec4(model.position, 0.0, 1.0); return out; +} + +@vertex +fn vs_click_animation_main( + model: VertexInput, +) -> VertexOutput { + var out: VertexOutput; + out.texture_coords = model.texture_coords; + out.clip_position = coords.transform * vec4(model.position, 0.0, 1.0); + return out; +} + +@group(2) @binding(0) +var radius: f32; + +@fragment +fn fs_click_animation_main(in: VertexOutput) -> @location(0) vec4 { + let color = textureSample(t_diffuse, s_diffuse, in.texture_coords); + let centered_coords = in.texture_coords - vec2(0.5, 0.5); + let dist = length(centered_coords); + + let radius_start = 0.1; + if radius == radius_start { + let alpha = 1.0 - smoothstep(radius, radius + 0.1, dist); + return vec4(color.rgb, color.a * alpha); + } else { + let ring_width = 0.01; + let edge = 0.02; + let outer = smoothstep(radius - ring_width - edge, radius - ring_width + edge, dist); + let inner = 1.0 - smoothstep(radius + ring_width - edge, radius + ring_width + edge, dist); + let alpha = inner * outer; + return vec4(color.rgb, color.a * alpha); + } } \ No newline at end of file diff --git a/core/src/input/mouse.rs b/core/src/input/mouse.rs index d33d3a0..c4785fa 100644 --- a/core/src/input/mouse.rs +++ b/core/src/input/mouse.rs @@ -322,6 +322,7 @@ struct ControllerCursor { */ clicked: bool, enabled: bool, + click_animation: bool, has_control: bool, visible_name: String, sid: String, @@ -340,6 +341,7 @@ impl ControllerCursor { pointer_cursor, clicked: false, enabled, + click_animation: false, has_control: false, visible_name, sid, @@ -427,6 +429,14 @@ impl ControllerCursor { fn has_control(&self) -> bool { self.has_control } + + fn set_click_animation(&mut self, click_animation: bool) { + self.click_animation = click_animation; + } + + fn click_animation(&self) -> bool { + self.click_animation + } } pub struct SharerCursor { @@ -488,8 +498,7 @@ impl SharerCursor { return; } - self.has_control = true; - self.cursor.hide(); + self.hide(); let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); for controller in controllers_cursors.iter_mut() { if controller.has_control() { @@ -504,7 +513,6 @@ impl SharerCursor { */ let mut cursor_simulator = self.cursor_simulator.lock().unwrap(); let global_position = self.global_position(); - cursor_simulator.simulate_cursor_movement(global_position, false); cursor_simulator.simulate_click(MouseClickData { x: global_position.x as f32, y: global_position.y as f32, @@ -516,13 +524,6 @@ impl SharerCursor { ctrl: false, meta: false, }); - - let res = self - .event_loop_proxy - .send_event(UserEvent::ParticipantInControl("sharer".to_string())); - if let Err(e) = res { - error!("sharer_cursor: click: error sending participant in control: {e:?}"); - } } fn scroll(&mut self) { @@ -532,32 +533,13 @@ impl SharerCursor { return; } - { - /* - * This is the same as the click, we need to move the system cursor to the - * position of the scroll, because the system cursor was were the controlling - * controller was. - */ - let mut cursor_simulator = self.cursor_simulator.lock().unwrap(); - let global_position = self.global_position(); - cursor_simulator.simulate_cursor_movement(global_position, false); - } - - self.has_control = true; - self.cursor.hide(); + self.hide(); let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); for controller in controllers_cursors.iter_mut() { if controller.has_control() { controller.show(); } } - - let res = self - .event_loop_proxy - .send_event(UserEvent::ParticipantInControl("sharer".to_string())); - if let Err(e) = res { - error!("sharer_cursor: scroll: error sending participant in control: {e:?}"); - } } fn has_control(&self) -> bool { @@ -579,6 +561,22 @@ impl SharerCursor { self.cursor.show(); } + fn hide(&mut self) { + self.has_control = true; + self.cursor.hide(); + + let mut cursor_simulator = self.cursor_simulator.lock().unwrap(); + let global_position = self.global_position(); + cursor_simulator.simulate_cursor_movement(global_position, false); + + let res = self + .event_loop_proxy + .send_event(UserEvent::ParticipantInControl("sharer".to_string())); + if let Err(e) = res { + error!("sharer_cursor: click: error sending participant in control: {e:?}"); + } + } + #[allow(dead_code)] fn set_last_event_position(&mut self, position: Position) { self.last_event_position = position; @@ -603,7 +601,7 @@ fn redraw_thread( receiver: Receiver, ) { loop { - match receiver.recv_timeout(std::time::Duration::from_millis(16)) { + match receiver.recv_timeout(std::time::Duration::from_millis(12)) { Ok(command) => match command { RedrawThreadCommands::Stop => break, }, @@ -923,6 +921,19 @@ impl CursorController { if !controller.enabled() { log::info!("mouse_click_controller: controller is disabled."); + if click_data.down && controller.click_animation() { + if let Err(e) = + self.event_loop_proxy + .send_event(UserEvent::EnableClickAnimation(Position { + x: click_data.x as f64, + y: click_data.y as f64, + })) + { + error!( + "mouse_click_controller: error sending enable click animation: {e:?}" + ); + } + } break; } @@ -1150,6 +1161,38 @@ impl CursorController { controller.draw(render_pass, gfx); } } + + pub fn set_controller_click_animation(&mut self, click_animation: bool, sid: &str) { + log::info!("set_controller_click_animation: {click_animation} {sid}"); + + let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); + for controller in controllers_cursors.iter_mut() { + if controller.sid != sid { + continue; + } + + if controller.has_control() { + log::info!("set_controller_click_animation: controller {sid} has control, give control back to sharer."); + controller.show(); + let mut sharer_cursor = self + .remote_control + .as_ref() + .unwrap() + .sharer_cursor + .lock() + .unwrap(); + sharer_cursor.hide(); + } + + // We disable the controller in order to not allow scrolling + // and taking control. Also we want the cursor to change. + if self.controllers_cursors_enabled { + controller.set_enabled(!click_animation); + } + controller.set_click_animation(click_animation); + break; + } + } } impl Drop for CursorController { diff --git a/core/src/lib.rs b/core/src/lib.rs index d37f37d..beb2e75 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -55,6 +55,7 @@ use winit::platform::windows::WindowExtWindows; use winit::window::{WindowAttributes, WindowLevel}; use crate::overlay_window::DisplayInfo; +use crate::utils::geometry::Position; // Constants for magic numbers /// Initial size for the overlay window (width and height in logical pixels) @@ -808,6 +809,25 @@ impl<'a> ApplicationHandler for Application<'a> { sentry_metadata.app_version, ); } + UserEvent::EnableClickAnimation(position) => { + log::debug!("user_event: Enable click animation: {position:?}"); + if self.remote_control.is_none() { + log::warn!("user_event: remote control is none enable click animation"); + return; + } + let gfx = &mut self.remote_control.as_mut().unwrap().gfx; + gfx.enable_click_animation(position); + } + UserEvent::ClickAnimation(visible, sid) => { + log::debug!("user_event: click animation: {visible:?} {sid}"); + if self.remote_control.is_none() { + log::warn!("user_event: remote control is none click animation"); + return; + } + let remote_control = &mut self.remote_control.as_mut().unwrap(); + let cursor_controller = &mut remote_control.cursor_controller; + cursor_controller.set_controller_click_animation(visible, sid.as_str()); + } } } @@ -884,6 +904,7 @@ pub enum UserEvent { ScreenShare(ScreenShareMessage), StopScreenShare, RequestRedraw, + EnableClickAnimation(Position), SharerPosition(f64, f64), ResetState, Tick(u128), @@ -893,6 +914,7 @@ pub enum UserEvent { ControllerTakesScreenShare, ParticipantInControl(String), SentryMetadata(SentryMetadata), + ClickAnimation(bool, String), } pub struct RenderEventLoop { diff --git a/core/src/room_service.rs b/core/src/room_service.rs index 27bec63..7a8e6c6 100644 --- a/core/src/room_service.rs +++ b/core/src/room_service.rs @@ -691,6 +691,15 @@ pub struct RemoteControlEnabled { pub enabled: bool, } +/// Contains the click animation enabled/disabled state. +/// +/// This structure is used to communicate whether click animation is currently enabled in the room. +#[derive(Debug, Serialize, Deserialize)] +pub struct ClickAnimation { + /// Whether click animation is currently enabled + pub enabled: bool, +} + /// Represents all possible client events that can be sent between room participants. /// /// This enum defines the different types of events that can be transmitted through @@ -716,6 +725,8 @@ pub enum ClientEvent { TickResponse(TickData), /// Remote control enabled/disabled status change RemoteControlEnabled(RemoteControlEnabled), + /// Enable/disable click animation for a remote controller + ClickAnimation(ClickAnimation), } async fn handle_room_events( @@ -806,6 +817,8 @@ async fn handle_room_events( Ok(()) } } + ClientEvent::ClickAnimation(click_animation) => event_loop_proxy + .send_event(UserEvent::ClickAnimation(click_animation.enabled, sid)), _ => Ok(()), }; if let Err(e) = res { diff --git a/core/tests/src/events.rs b/core/tests/src/events.rs index 2a34c64..10f7c3e 100644 --- a/core/tests/src/events.rs +++ b/core/tests/src/events.rs @@ -53,6 +53,11 @@ pub struct RemoteControlEnabled { pub enabled: bool, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ClickAnimation { + pub enabled: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type", content = "payload")] pub enum ClientEvent { @@ -65,4 +70,5 @@ pub enum ClientEvent { Tick(TickData), TickResponse(TickData), RemoteControlEnabled(RemoteControlEnabled), + ClickAnimation(ClickAnimation), } diff --git a/core/tests/src/main.rs b/core/tests/src/main.rs index e618e9e..c2430dc 100644 --- a/core/tests/src/main.rs +++ b/core/tests/src/main.rs @@ -56,6 +56,8 @@ enum CursorTest { WindowEdges, /// Test concurrent scrolling ConcurrentScrolling, + /// Click animation + ClickAnimation, } #[tokio::main] @@ -119,6 +121,10 @@ async fn main() -> io::Result<()> { println!("Running concurrent scrolling test..."); remote_cursor::test_concurrent_scrolling().await?; } + CursorTest::ClickAnimation => { + println!("Running click animation test..."); + remote_cursor::test_click_animation().await?; + } } println!("Cursor test finished."); } diff --git a/core/tests/src/remote_cursor.rs b/core/tests/src/remote_cursor.rs index ede0cbc..b5da618 100644 --- a/core/tests/src/remote_cursor.rs +++ b/core/tests/src/remote_cursor.rs @@ -1,8 +1,9 @@ -use crate::events::{ClientEvent, ClientPoint, MouseClickData, WheelDelta}; +use crate::events::{ClickAnimation, ClientEvent, ClientPoint, MouseClickData, WheelDelta}; use crate::livekit_utils; use crate::screenshare_client; use livekit::prelude::*; use rand::{rngs::StdRng, Rng, SeedableRng}; +use socket_lib::Message; use std::{io, time::Duration}; use tokio::time::sleep; @@ -60,6 +61,45 @@ async fn send_mouse_click(room: &Room, x: f64, y: f64, button: u32) -> io::Resul Ok(()) } +/// Sends a ClickAnimation event via the LiveKit data channel. +async fn send_click_animation(room: &Room, enabled: bool) -> io::Result<()> { + let click_animation_data = ClickAnimation { enabled }; + let event = ClientEvent::ClickAnimation(click_animation_data); + let payload = serde_json::to_vec(&event).map_err(io::Error::other)?; + room.local_participant() + .publish_data(DataPacket { + payload, + reliable: true, + ..Default::default() + }) + .await + .map_err(io::Error::other)?; + Ok(()) +} + +/// Smoothly moves the cursor from one position to another +async fn smooth_cursor_move( + room: &Room, + from_x: f64, + from_y: f64, + to_x: f64, + to_y: f64, +) -> io::Result<()> { + let steps = 30; // Number of steps for smooth movement + let delay = Duration::from_millis(16); // ~60fps + + for i in 0..=steps { + let t = i as f64 / steps as f64; + let x = from_x + (to_x - from_x) * t; + let y = from_y + (to_y - from_y) * t; + + send_mouse_move(room, x, y).await?; + sleep(delay).await; + } + + Ok(()) +} + /// Sends a mouse move event via the LiveKit data channel. async fn send_mouse_move(room: &Room, x: f64, y: f64) -> io::Result<()> { let point = ClientPoint { @@ -1210,3 +1250,152 @@ pub async fn test_concurrent_scrolling() -> io::Result<()> { Ok(()) } + +/// Test click animation with various remote control states +pub async fn test_click_animation() -> io::Result<()> { + // Start single screenshare session + println!("Starting screenshare session..."); + let (mut cursor_socket, _) = screenshare_client::start_screenshare_session()?; + + let url = std::env::var("LIVEKIT_URL").expect("LIVEKIT_URL environment variable not set"); + + // Create single participant connection + let token = livekit_utils::generate_token("TestUser"); + let (room, _rx) = Room::connect(&url, &token, RoomOptions::default()) + .await + .unwrap(); + + println!("Participant connected."); + + // We need to wait for the textures to load + tokio::time::sleep(std::time::Duration::from_secs(7)).await; + + // Starting position - middle of screen + let start_x = 0.5; + let start_y = 0.5; + let move_distance = 0.2; + + // Track current cursor position - start slightly to the left + let mut current_x = start_x - 0.1; + let mut current_y = start_y; + + println!("\n=== Phase 1: Click Animation Enabled with Remote Control ==="); + // Move to starting position before first click + println!("Moving to starting position ({start_x}, {current_y})"); + smooth_cursor_move(&room, current_x, current_y, start_x, current_y).await?; + current_x = start_x; + sleep(Duration::from_millis(500)).await; + + // Enable click animation + println!("Enabling click animation"); + send_click_animation(&room, true).await?; + sleep(Duration::from_millis(200)).await; + + // Perform a click + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + // Move to the right + let new_x = current_x + move_distance; + println!("Moving to the right to ({new_x}, {current_y})"); + smooth_cursor_move(&room, current_x, current_y, new_x, current_y).await?; + current_x = new_x; + sleep(Duration::from_millis(500)).await; + + // Perform a click + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + // Disable click animation + println!("Disabling click animation"); + send_click_animation(&room, false).await?; + sleep(Duration::from_millis(200)).await; + + println!("\n=== Phase 2: Remote Control Disabled ==="); + // Disable remote control + println!("Disabling remote control"); + cursor_socket.send_message(Message::ControllerCursorEnabled(false))?; + sleep(Duration::from_millis(500)).await; + + // Move down + let new_y = current_y + move_distance; + println!("Moving down to ({current_x}, {new_y})"); + smooth_cursor_move(&room, current_x, current_y, current_x, new_y).await?; + current_y = new_y; + sleep(Duration::from_millis(500)).await; + + // Enable click animation + println!("Enabling click animation"); + send_click_animation(&room, true).await?; + sleep(Duration::from_millis(200)).await; + + // Click - move right - click + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + let new_x2 = current_x + move_distance; + println!("Moving right to ({new_x2}, {current_y})"); + smooth_cursor_move(&room, current_x, current_y, new_x2, current_y).await?; + current_x = new_x2; + sleep(Duration::from_millis(500)).await; + + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + println!("\n=== Phase 3: Remote Control Re-enabled ==="); + // Enable remote control + println!("Enabling remote control"); + cursor_socket.send_message(Message::ControllerCursorEnabled(true))?; + sleep(Duration::from_millis(500)).await; + + // Disable click animation + println!("Disabling click animation"); + send_click_animation(&room, false).await?; + sleep(Duration::from_millis(200)).await; + + // Click + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + // Move up + let new_y2 = current_y - move_distance; + println!("Moving up to ({current_x}, {new_y2})"); + smooth_cursor_move(&room, current_x, current_y, current_x, new_y2).await?; + current_y = new_y2; + sleep(Duration::from_millis(500)).await; + + println!("\n=== Phase 4: Final Click Animation Sequence ==="); + // Enable click animation + println!("Enabling click animation"); + send_click_animation(&room, true).await?; + sleep(Duration::from_millis(200)).await; + + // Click - move right - click + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + let final_x = current_x + move_distance; + println!("Moving right to ({final_x}, {current_y})"); + smooth_cursor_move(&room, current_x, current_y, final_x, current_y).await?; + current_x = final_x; + sleep(Duration::from_millis(500)).await; + + println!("Clicking at ({current_x}, {current_y})"); + send_mouse_click(&room, current_x, current_y, 0).await?; + sleep(Duration::from_millis(500)).await; + + println!("\n=== TEST COMPLETED ==="); + println!("All click animation phases completed successfully."); + + // Stop the screenshare session + screenshare_client::stop_screenshare_session(&mut cursor_socket)?; + println!("Screenshare session stopped."); + + Ok(()) +} diff --git a/core/tests/src/screenshare_client.rs b/core/tests/src/screenshare_client.rs index ec288ba..3ca06fa 100644 --- a/core/tests/src/screenshare_client.rs +++ b/core/tests/src/screenshare_client.rs @@ -37,8 +37,17 @@ pub fn request_screenshare( }, token, resolution: Extent { width, height }, + accessibility_permission: true, + use_av1: false, }); - socket.send_message(message) + socket.send_message(message).unwrap(); + + match socket.receive_message() { + Ok(_message) => Ok(()), + Err(e) => Err(io::Error::other(format!( + "Failed to receive message: {e:?}" + ))), + } } /// Sends a request to stop screen sharing. From 26f9b2a0fb31587ac309611edd639c65c8ea9d5a Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 7 Oct 2025 14:54:05 +0100 Subject: [PATCH 2/7] feat(frontend): add click animation button in sharing screen --- .../src/components/SharingScreen/Controls.tsx | 46 ++++++++++------- .../SharingScreen/SharingScreen.tsx | 13 ++++- tauri/src/components/ui/segmented-control.tsx | 49 ++++++++++++++++++- tauri/src/payloads.ts | 8 +++ tauri/src/windows/screensharing/context.tsx | 5 ++ 5 files changed, 98 insertions(+), 23 deletions(-) diff --git a/tauri/src/components/SharingScreen/Controls.tsx b/tauri/src/components/SharingScreen/Controls.tsx index 33238a5..8cea512 100644 --- a/tauri/src/components/SharingScreen/Controls.tsx +++ b/tauri/src/components/SharingScreen/Controls.tsx @@ -4,11 +4,12 @@ import { useSharingContext } from "@/windows/screensharing/context"; import { TooltipContent, TooltipTrigger, Tooltip, TooltipProvider } from "../ui/tooltip"; import { BiSolidJoystick } from "react-icons/bi"; import useStore from "@/store/store"; -import { SegmentedControl } from "../ui/segmented-control"; +import { SegmentedControl, ClickAnimationButton } from "../ui/segmented-control"; import { useState } from "react"; export function ScreenSharingControls() { - const { setIsSharingKeyEvents, setIsSharingMouse } = useSharingContext(); + const { setIsSharingKeyEvents, setIsSharingMouse, clickAnimationEnabled, setClickAnimationEnabled } = + useSharingContext(); const isRemoteControlEnabled = useStore((state) => state.callTokens?.isRemoteControlEnabled); const [remoteControlStatus, setRemoteControlStatus] = useState("controlling"); @@ -23,27 +24,34 @@ export function ScreenSharingControls() { } }; + const handleClickAnimationToggle = () => { + setClickAnimationEnabled(!clickAnimationEnabled); + }; + return (
- , - tooltipContent: "Remote control", - }, - { - id: "pointing", - content: , - tooltipContent: "Pointing", - }, - ]} - value={remoteControlStatus} - onValueChange={handleRemoteControlChange} - className="pointer-events-auto" - /> +
+ , + tooltipContent: "Remote control", + }, + { + id: "pointing", + content: , + tooltipContent: "Pointing", + }, + ]} + value={remoteControlStatus} + onValueChange={handleRemoteControlChange} + className="pointer-events-auto" + /> + +
{isRemoteControlEnabled === false && (
diff --git a/tauri/src/components/SharingScreen/SharingScreen.tsx b/tauri/src/components/SharingScreen/SharingScreen.tsx index 3ec6b14..9bbde13 100644 --- a/tauri/src/components/SharingScreen/SharingScreen.tsx +++ b/tauri/src/components/SharingScreen/SharingScreen.tsx @@ -10,6 +10,7 @@ import { useSharingContext } from "@/windows/screensharing/context"; import { useResizeListener } from "@/lib/hooks"; import { cn, getAbsolutePosition, getRelativePosition } from "@/lib/utils"; import { + TPClickAnimation, TPKeystroke, TPMouseClick, TPMouseMove, @@ -79,7 +80,7 @@ const ConsumerComponent = React.memo(() => { onlySubscribed: true, }); const localParticipant = useLocalParticipant(); - let { isSharingMouse, isSharingKeyEvents, parentKeyTrap } = useSharingContext(); + let { isSharingMouse, isSharingKeyEvents, parentKeyTrap, clickAnimationEnabled } = useSharingContext(); const [wrapperRef, isMouseInside] = useHover(); const { updateCallTokens } = useStore(); const [mouse, mouseRef] = useMouse(); @@ -361,6 +362,14 @@ const ConsumerComponent = React.memo(() => { localParticipant.localParticipant?.publishData(encoder.encode(JSON.stringify(payload)), { reliable: true }); } + if (videoElement) { + const payload: TPClickAnimation = { + type: "ClickAnimation", + payload: { enabled: clickAnimationEnabled }, + }; + localParticipant.localParticipant?.publishData(encoder.encode(JSON.stringify(payload)), { reliable: true }); + } + if (videoElement) { videoElement.addEventListener("mousemove", handleMouseMove); } @@ -381,7 +390,7 @@ const ConsumerComponent = React.memo(() => { videoElement.removeEventListener("contextmenu", handleContextMenu); } }; - }, [isSharingMouse, updateMouseControls]); + }, [isSharingMouse, updateMouseControls, clickAnimationEnabled]); /** * Keyboard sharing logic diff --git a/tauri/src/components/ui/segmented-control.tsx b/tauri/src/components/ui/segmented-control.tsx index e9cf44a..5a98e77 100644 --- a/tauri/src/components/ui/segmented-control.tsx +++ b/tauri/src/components/ui/segmented-control.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { motion, LayoutGroup } from "framer-motion"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; +import { HiOutlineCursorArrowRipple } from "react-icons/hi2"; interface SegmentedControlItem { id: string; @@ -143,5 +144,49 @@ const SegmentedControl = React.forwardRef void; +} + +const ClickAnimationButton = React.forwardRef( + ({ enabled, onToggle }, ref) => { + return ( + + + +
+ {enabled && ( + + )} + + + +
+
+
+ Highlight clicks +
+ ); + }, +); + +ClickAnimationButton.displayName = "ClickAnimationButton"; + +export { SegmentedControl, ClickAnimationButton }; +export type { SegmentedControlProps, SegmentedControlItem, ClickAnimationButtonProps }; diff --git a/tauri/src/payloads.ts b/tauri/src/payloads.ts index 6e72c17..2c6de4a 100644 --- a/tauri/src/payloads.ts +++ b/tauri/src/payloads.ts @@ -70,6 +70,14 @@ export const PKeystroke = z.object({ }); export type TPKeystroke = z.infer; +export const PClickAnimation = z.object({ + type: z.literal("ClickAnimation"), + payload: z.object({ + enabled: z.boolean(), + }), +}); +export type TPClickAnimation = z.infer; + // WebSocket Message Types export const MessageType = z.enum([ "success", diff --git a/tauri/src/windows/screensharing/context.tsx b/tauri/src/windows/screensharing/context.tsx index 4ba68cd..77a61ab 100644 --- a/tauri/src/windows/screensharing/context.tsx +++ b/tauri/src/windows/screensharing/context.tsx @@ -3,9 +3,11 @@ import React, { createContext, useContext, useState, ReactNode } from "react"; type SharingContextType = { isSharingMouse: boolean; isSharingKeyEvents: boolean; + clickAnimationEnabled: boolean; videoToken: string | null; setIsSharingMouse: (value: boolean) => void; setIsSharingKeyEvents: (value: boolean) => void; + setClickAnimationEnabled: (value: boolean) => void; setVideoToken: (value: string) => void; parentKeyTrap?: HTMLDivElement; setParentKeyTrap: (value: HTMLDivElement) => void; @@ -28,6 +30,7 @@ type SharingProviderProps = { export const SharingProvider: React.FC = ({ children }) => { const [isSharingMouse, setIsSharingMouse] = useState(true); const [isSharingKeyEvents, setIsSharingKeyEvents] = useState(true); + const [clickAnimationEnabled, setClickAnimationEnabled] = useState(false); const [parentKeyTrap, setParentKeyTrap] = useState(undefined); const [videoToken, setVideoToken] = useState(null); @@ -36,8 +39,10 @@ export const SharingProvider: React.FC = ({ children }) => value={{ isSharingMouse, isSharingKeyEvents, + clickAnimationEnabled, setIsSharingMouse, setIsSharingKeyEvents, + setClickAnimationEnabled, parentKeyTrap, setParentKeyTrap, videoToken, From 41f146be694868f75a44ed0aac586d54dacc9c2f Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 7 Oct 2025 15:29:09 +0100 Subject: [PATCH 3/7] fix: make contentPicker window alwaysOnTop --- tauri/src/windows/window-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tauri/src/windows/window-utils.ts b/tauri/src/windows/window-utils.ts index 5751195..faf6968 100644 --- a/tauri/src/windows/window-utils.ts +++ b/tauri/src/windows/window-utils.ts @@ -64,7 +64,7 @@ const createContentPickerWindow = async (videoToken: string, useAv1: boolean) => hiddenTitle: true, titleBarStyle: "overlay", resizable: true, - alwaysOnTop: false, + alwaysOnTop: true, visible: true, title: "Content picker", }); From 97baeb76a490ee21bba86d526cb56f6611c50a62 Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 7 Oct 2025 15:58:33 +0100 Subject: [PATCH 4/7] chore: fix clippy --- core/src/graphics/click_animation.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/core/src/graphics/click_animation.rs b/core/src/graphics/click_animation.rs index b08ec59..3c9feb5 100644 --- a/core/src/graphics/click_animation.rs +++ b/core/src/graphics/click_animation.rs @@ -57,7 +57,7 @@ impl ClickAnimation { /// at the appropriate offset in the shared buffer. pub fn update_transform_buffer(&self, queue: &wgpu::Queue, transforms_buffer: &wgpu::Buffer) { queue.write_buffer( - &transforms_buffer, + transforms_buffer, self.transform_offset as wgpu::BufferAddress, bytemuck::cast_slice(&[self.position.get_transform_matrix()]), ); @@ -71,7 +71,7 @@ impl ClickAnimation { /// * `radius` - Current radius value for the animation pub fn update_radius(&self, queue: &wgpu::Queue, radius_buffer: &wgpu::Buffer, radius: f32) { queue.write_buffer( - &radius_buffer, + radius_buffer, self.radius_offset as wgpu::BufferAddress, bytemuck::cast_slice(&[radius]), ); @@ -96,8 +96,8 @@ impl ClickAnimation { ) { self.position .set_position(position.x as f32, position.y as f32); - self.update_transform_buffer(queue, &transforms_buffer); - self.update_radius(queue, &radius_buffer, 0.1); + self.update_transform_buffer(queue, transforms_buffer); + self.update_radius(queue, radius_buffer, 0.1); self.enabled_instant = Some(std::time::Instant::now()); } @@ -111,7 +111,7 @@ impl ClickAnimation { /// clears the enabled timestamp. pub fn disable(&mut self, queue: &wgpu::Queue, transforms_buffer: &wgpu::Buffer) { self.position.set_position(-100.0, -100.0); - self.update_transform_buffer(queue, &transforms_buffer); + self.update_transform_buffer(queue, transforms_buffer); self.enabled_instant = None; } @@ -147,7 +147,7 @@ impl ClickAnimation { self.update_radius(queue, radius_buffer, radius); } if elapsed > 1000 { - self.disable(queue, &radius_buffer); + self.disable(queue, radius_buffer); } render_pass.set_bind_group(0, &self.texture.bind_group, &[]); render_pass.set_bind_group(1, transforms_bind_group, &[self.transform_offset]); @@ -243,7 +243,7 @@ impl ClickAnimationRenderer { device: &wgpu::Device, queue: &wgpu::Queue, texture_format: wgpu::TextureFormat, - texture_path: &String, + texture_path: &str, window_size: Extent, scale: f64, ) -> Result { @@ -440,7 +440,7 @@ impl ClickAnimationRenderer { let mut available_slots = VecDeque::new(); for i in 0..MAX_ANIMATIONS { let click_animation = Self::create_click_animation(ClickAnimationCreateData { - texture_path: texture_path.clone(), + texture_path: texture_path.to_owned(), scale, device, queue, @@ -517,7 +517,7 @@ impl ClickAnimationRenderer { data.device, data.queue, &image_data, - &data.texture_bind_group_layout, + data.texture_bind_group_layout, )?; // Create vertex and index buffers for animation geometry @@ -542,7 +542,7 @@ impl ClickAnimationRenderer { // Upload initial transform matrix to GPU data.queue.write_buffer( - &data.transforms_buffer, + data.transforms_buffer, transform_offset, bytemuck::cast_slice(&[point.get_transform_matrix()]), ); @@ -550,7 +550,7 @@ impl ClickAnimationRenderer { let radius_offset = (data.animations_created as wgpu::BufferAddress) * data.radius_buffer_entry_offset; data.queue.write_buffer( - &data.radius_buffer, + data.radius_buffer, radius_offset, bytemuck::cast_slice(&[0.0f32]), ); @@ -703,7 +703,7 @@ impl ClickAnimationRenderer { break; } - let slot = front.unwrap().clone(); + let slot = *front.unwrap(); if self.click_animations[slot].enabled_instant.is_none() { let front = self.used_slots.pop_front().unwrap(); self.available_slots.push_back(front); From d33abcc4bc3ee900b914bf2d6709acc228b51028 Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 7 Oct 2025 19:44:17 +0100 Subject: [PATCH 5/7] fix: reset to having two buttons --- core/src/input/mouse.rs | 116 +++--- core/src/lib.rs | 13 +- core/src/room_service.rs | 13 - core/tests/src/events.rs | 6 - core/tests/src/main.rs | 24 ++ core/tests/src/remote_cursor.rs | 346 +++++++++++++++++- .../src/components/SharingScreen/Controls.tsx | 10 +- .../SharingScreen/SharingScreen.tsx | 17 +- tauri/src/components/ui/segmented-control.tsx | 49 +-- tauri/src/payloads.ts | 8 - tauri/src/windows/screensharing/context.tsx | 5 - 11 files changed, 433 insertions(+), 174 deletions(-) diff --git a/core/src/input/mouse.rs b/core/src/input/mouse.rs index c4785fa..91153f9 100644 --- a/core/src/input/mouse.rs +++ b/core/src/input/mouse.rs @@ -322,7 +322,7 @@ struct ControllerCursor { */ clicked: bool, enabled: bool, - click_animation: bool, + pointer_enabled: bool, has_control: bool, visible_name: String, sid: String, @@ -341,7 +341,7 @@ impl ControllerCursor { pointer_cursor, clicked: false, enabled, - click_animation: false, + pointer_enabled: false, has_control: false, visible_name, sid, @@ -354,23 +354,23 @@ impl ControllerCursor { global_position, local_position, self.has_control, - self.enabled + self.enabled_control_cursor() ); self.control_cursor.set_position( global_position, local_position, - !self.has_control && self.enabled, + !self.has_control && self.enabled_control_cursor(), ); self.pointer_cursor.set_position( global_position, local_position, - !self.has_control && !self.enabled, + !self.has_control && !self.enabled_control_cursor(), ); } fn show(&mut self) { self.has_control = false; - if self.enabled { + if self.enabled_control_cursor() { self.control_cursor.show(); } else { self.pointer_cursor.show(); @@ -378,7 +378,7 @@ impl ControllerCursor { } fn hide(&mut self) { - if self.enabled { + if self.enabled_control_cursor() { self.has_control = true; self.control_cursor.hide(); } else { @@ -386,6 +386,10 @@ impl ControllerCursor { } } + fn enabled_control_cursor(&self) -> bool { + self.enabled && !self.pointer_enabled + } + fn enabled(&self) -> bool { self.enabled } @@ -393,7 +397,7 @@ impl ControllerCursor { fn set_enabled(&mut self, enabled: bool) { self.enabled = enabled; - if enabled { + if enabled && !self.pointer_enabled { self.control_cursor.show(); self.pointer_cursor.hide(); } else { @@ -419,7 +423,7 @@ impl ControllerCursor { return; } - if self.enabled { + if self.enabled && !self.pointer_enabled { self.control_cursor.draw(render_pass, gfx); } else { self.pointer_cursor.draw(render_pass, gfx); @@ -430,12 +434,20 @@ impl ControllerCursor { self.has_control } - fn set_click_animation(&mut self, click_animation: bool) { - self.click_animation = click_animation; + fn set_pointer_enabled(&mut self, pointer_enabled: bool) { + self.pointer_enabled = pointer_enabled; + + if pointer_enabled { + self.pointer_cursor.show(); + self.control_cursor.hide(); + } else if self.enabled { + self.pointer_cursor.hide(); + self.control_cursor.show(); + } } - fn click_animation(&self) -> bool { - self.click_animation + fn pointer_enabled(&self) -> bool { + self.pointer_enabled } } @@ -919,9 +931,9 @@ impl CursorController { continue; } - if !controller.enabled() { + if !controller.enabled() || controller.pointer_enabled() { log::info!("mouse_click_controller: controller is disabled."); - if click_data.down && controller.click_animation() { + if click_data.down && controller.pointer_enabled() { if let Err(e) = self.event_loop_proxy .send_event(UserEvent::EnableClickAnimation(Position { @@ -1025,7 +1037,7 @@ impl CursorController { continue; } - if !controller.enabled() { + if !controller.enabled() || controller.pointer_enabled() { log::info!("scroll_controller: controller is disabled."); break; } @@ -1100,6 +1112,18 @@ impl CursorController { self.controllers_cursors_enabled = enabled; for controller in controllers_cursors.iter_mut() { controller.set_enabled(enabled); + + if controller.has_control() { + controller.show(); + let mut sharer_cursor = self + .remote_control + .as_ref() + .unwrap() + .sharer_cursor + .lock() + .unwrap(); + sharer_cursor.hide(); + } } } @@ -1109,17 +1133,10 @@ impl CursorController { /// /// # Parameters /// - /// * `visible` - Whether to show full cursor (true) or minimal pointer (false) + /// * `enabled` - Whether to show full cursor (true) or minimal pointer (false) /// * `sid` - Session ID identifying which controller to modify - pub fn set_controller_visible(&mut self, visible: bool, sid: &str) { - log::info!("set_controller_visible: {visible} {sid}"); - - if !self.controllers_cursors_enabled { - log::info!( - "set_controller_visible: sharer has disabled controllers' cursors this is a noop." - ); - return; - } + pub fn set_controller_pointer_enabled(&mut self, enabled: bool, sid: &str) { + log::info!("set_controller_pointer_enabled: {enabled} {sid}"); let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); for controller in controllers_cursors.iter_mut() { @@ -1127,7 +1144,20 @@ impl CursorController { continue; } - controller.set_enabled(visible); + if controller.has_control() { + log::info!("set_controller_pointer_enabled: controller {sid} has control, give control back to sharer."); + controller.show(); + let mut sharer_cursor = self + .remote_control + .as_ref() + .unwrap() + .sharer_cursor + .lock() + .unwrap(); + sharer_cursor.hide(); + } + + controller.set_pointer_enabled(enabled); break; } } @@ -1161,38 +1191,6 @@ impl CursorController { controller.draw(render_pass, gfx); } } - - pub fn set_controller_click_animation(&mut self, click_animation: bool, sid: &str) { - log::info!("set_controller_click_animation: {click_animation} {sid}"); - - let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); - for controller in controllers_cursors.iter_mut() { - if controller.sid != sid { - continue; - } - - if controller.has_control() { - log::info!("set_controller_click_animation: controller {sid} has control, give control back to sharer."); - controller.show(); - let mut sharer_cursor = self - .remote_control - .as_ref() - .unwrap() - .sharer_cursor - .lock() - .unwrap(); - sharer_cursor.hide(); - } - - // We disable the controller in order to not allow scrolling - // and taking control. Also we want the cursor to change. - if self.controllers_cursors_enabled { - controller.set_enabled(!click_animation); - } - controller.set_click_animation(click_animation); - break; - } - } } impl Drop for CursorController { diff --git a/core/src/lib.rs b/core/src/lib.rs index beb2e75..d8a1b1d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -643,7 +643,7 @@ impl<'a> ApplicationHandler for Application<'a> { } let remote_control = &mut self.remote_control.as_mut().unwrap(); let cursor_controller = &mut remote_control.cursor_controller; - cursor_controller.set_controller_visible(visible, sid.as_str()); + cursor_controller.set_controller_pointer_enabled(visible, sid.as_str()); } UserEvent::Keystroke(keystroke_data) => { log::debug!("user_event: keystroke: {keystroke_data:?}"); @@ -818,16 +818,6 @@ impl<'a> ApplicationHandler for Application<'a> { let gfx = &mut self.remote_control.as_mut().unwrap().gfx; gfx.enable_click_animation(position); } - UserEvent::ClickAnimation(visible, sid) => { - log::debug!("user_event: click animation: {visible:?} {sid}"); - if self.remote_control.is_none() { - log::warn!("user_event: remote control is none click animation"); - return; - } - let remote_control = &mut self.remote_control.as_mut().unwrap(); - let cursor_controller = &mut remote_control.cursor_controller; - cursor_controller.set_controller_click_animation(visible, sid.as_str()); - } } } @@ -914,7 +904,6 @@ pub enum UserEvent { ControllerTakesScreenShare, ParticipantInControl(String), SentryMetadata(SentryMetadata), - ClickAnimation(bool, String), } pub struct RenderEventLoop { diff --git a/core/src/room_service.rs b/core/src/room_service.rs index 7a8e6c6..27bec63 100644 --- a/core/src/room_service.rs +++ b/core/src/room_service.rs @@ -691,15 +691,6 @@ pub struct RemoteControlEnabled { pub enabled: bool, } -/// Contains the click animation enabled/disabled state. -/// -/// This structure is used to communicate whether click animation is currently enabled in the room. -#[derive(Debug, Serialize, Deserialize)] -pub struct ClickAnimation { - /// Whether click animation is currently enabled - pub enabled: bool, -} - /// Represents all possible client events that can be sent between room participants. /// /// This enum defines the different types of events that can be transmitted through @@ -725,8 +716,6 @@ pub enum ClientEvent { TickResponse(TickData), /// Remote control enabled/disabled status change RemoteControlEnabled(RemoteControlEnabled), - /// Enable/disable click animation for a remote controller - ClickAnimation(ClickAnimation), } async fn handle_room_events( @@ -817,8 +806,6 @@ async fn handle_room_events( Ok(()) } } - ClientEvent::ClickAnimation(click_animation) => event_loop_proxy - .send_event(UserEvent::ClickAnimation(click_animation.enabled, sid)), _ => Ok(()), }; if let Err(e) = res { diff --git a/core/tests/src/events.rs b/core/tests/src/events.rs index 10f7c3e..2a34c64 100644 --- a/core/tests/src/events.rs +++ b/core/tests/src/events.rs @@ -53,11 +53,6 @@ pub struct RemoteControlEnabled { pub enabled: bool, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ClickAnimation { - pub enabled: bool, -} - #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type", content = "payload")] pub enum ClientEvent { @@ -70,5 +65,4 @@ pub enum ClientEvent { Tick(TickData), TickResponse(TickData), RemoteControlEnabled(RemoteControlEnabled), - ClickAnimation(ClickAnimation), } diff --git a/core/tests/src/main.rs b/core/tests/src/main.rs index c2430dc..f4e5e0b 100644 --- a/core/tests/src/main.rs +++ b/core/tests/src/main.rs @@ -58,6 +58,14 @@ enum CursorTest { ConcurrentScrolling, /// Click animation ClickAnimation, + /// Test transitions: Remote enabled with animation toggling + TransitionsRemoteEnabledAnimation, + /// Test transitions: Remote enabled then disabled + TransitionsRemoteEnabledThenDisabled, + /// Test transitions: Remote disabled with animation + TransitionsRemoteDisabledAnimation, + /// Test transitions: Mixed remote control and animation + TransitionsMixed, } #[tokio::main] @@ -125,6 +133,22 @@ async fn main() -> io::Result<()> { println!("Running click animation test..."); remote_cursor::test_click_animation().await?; } + CursorTest::TransitionsRemoteEnabledAnimation => { + println!("Running transitions test: Remote enabled with animation..."); + remote_cursor::test_transitions_remote_enabled_with_animation().await?; + } + CursorTest::TransitionsRemoteEnabledThenDisabled => { + println!("Running transitions test: Remote enabled then disabled..."); + remote_cursor::test_transitions_remote_enabled_then_disabled().await?; + } + CursorTest::TransitionsRemoteDisabledAnimation => { + println!("Running transitions test: Remote disabled with animation..."); + remote_cursor::test_transitions_remote_disabled_with_animation().await?; + } + CursorTest::TransitionsMixed => { + println!("Running transitions test: Mixed remote and animation..."); + remote_cursor::test_transitions_mixed_remote_and_animation().await?; + } } println!("Cursor test finished."); } diff --git a/core/tests/src/remote_cursor.rs b/core/tests/src/remote_cursor.rs index b5da618..e3f2430 100644 --- a/core/tests/src/remote_cursor.rs +++ b/core/tests/src/remote_cursor.rs @@ -1,4 +1,4 @@ -use crate::events::{ClickAnimation, ClientEvent, ClientPoint, MouseClickData, WheelDelta}; +use crate::events::{ClientEvent, ClientPoint, MouseClickData, MouseVisibleData, WheelDelta}; use crate::livekit_utils; use crate::screenshare_client; use livekit::prelude::*; @@ -63,8 +63,8 @@ async fn send_mouse_click(room: &Room, x: f64, y: f64, button: u32) -> io::Resul /// Sends a ClickAnimation event via the LiveKit data channel. async fn send_click_animation(room: &Room, enabled: bool) -> io::Result<()> { - let click_animation_data = ClickAnimation { enabled }; - let event = ClientEvent::ClickAnimation(click_animation_data); + let click_animation_data = MouseVisibleData { visible: enabled }; + let event = ClientEvent::MouseVisible(click_animation_data); let payload = serde_json::to_vec(&event).map_err(io::Error::other)?; room.local_participant() .publish_data(DataPacket { @@ -1399,3 +1399,343 @@ pub async fn test_click_animation() -> io::Result<()> { Ok(()) } + +/// Test cursor transitions with remote control enabled - Animation toggling +/// Expected behavior: +/// - Move: normal cursor (no control) +/// - Click: take control +/// - Move: give control back -> pointy finger +/// - Enable animation: pointy finger still +/// - Move: pointy finger still +/// - Click: click animation plays +/// - Disable animation: back to pointy finger +/// - Move: pointy finger +/// - Click: take control again (no animation) +pub async fn test_transitions_remote_enabled_with_animation() -> io::Result<()> { + println!("\n=== TEST: Remote Control Enabled - Animation Toggling ==="); + let (mut cursor_socket, _) = screenshare_client::start_screenshare_session()?; + + let url = std::env::var("LIVEKIT_URL").expect("LIVEKIT_URL environment variable not set"); + let token = livekit_utils::generate_token("TestUser"); + let (room, _rx) = Room::connect(&url, &token, RoomOptions::default()) + .await + .unwrap(); + + println!("Participant connected. Waiting for textures..."); + tokio::time::sleep(std::time::Duration::from_secs(7)).await; + + let mut x = 0.3; + let y = 0.3; + let step = 0.15; + + // Move (normal cursor, no control) + println!("\n1. Move - Expected: normal cursor (no control)"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (take control) + println!("\n2. Click - Expected: take control"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (give control back, pointy finger) + println!("\n3. Move - Expected: give control back -> pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Enable animation (pointy finger still) + println!("\n4. Enable animation - Expected: pointy finger still"); + send_click_animation(&room, true).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n5. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (click animation) + println!("\n6. Click - Expected: click animation plays"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Disable animation (back to pointy finger) + println!("\n7. Disable animation - Expected: back to pointy finger"); + send_click_animation(&room, false).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n8. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (take control, no animation) + println!("\n9. Click - Expected: take control (no animation)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + println!("\n=== TEST COMPLETED ==="); + screenshare_client::stop_screenshare_session(&mut cursor_socket)?; + Ok(()) +} + +/// Test cursor transitions with remote control toggling +/// Expected behavior: +/// - Move: normal cursor (no control) +/// - Click: take control +/// - Move: give control back -> pointy finger +/// - Disable remote control: pointy finger still (but clicks won't work) +/// - Move: pointy finger +/// - Click: noop (no action, stays pointy finger) +pub async fn test_transitions_remote_enabled_then_disabled() -> io::Result<()> { + println!("\n=== TEST: Remote Control Enabled Then Disabled ==="); + let (mut cursor_socket, _) = screenshare_client::start_screenshare_session()?; + + let url = std::env::var("LIVEKIT_URL").expect("LIVEKIT_URL environment variable not set"); + let token = livekit_utils::generate_token("TestUser"); + let (room, _rx) = Room::connect(&url, &token, RoomOptions::default()) + .await + .unwrap(); + + println!("Participant connected. Waiting for textures..."); + tokio::time::sleep(std::time::Duration::from_secs(7)).await; + + let mut x = 0.3; + let y = 0.4; + let step = 0.15; + + // Move (normal cursor, no control) + println!("\n1. Move - Expected: normal cursor (no control)"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (take control) + println!("\n2. Click - Expected: take control"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (give control back, pointy finger) + println!("\n3. Move - Expected: give control back -> pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Disable remote control + println!("\n4. Disable ControllerCursorEnabled - Expected: pointy finger (but no interaction)"); + cursor_socket.send_message(Message::ControllerCursorEnabled(false))?; + sleep(Duration::from_millis(1000)).await; + + // Move (still pointy finger) + println!("\n5. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (noop - no action) + println!("\n6. Click - Expected: noop (no action, stays pointy finger)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + println!("\n=== TEST COMPLETED ==="); + screenshare_client::stop_screenshare_session(&mut cursor_socket)?; + Ok(()) +} + +/// Test cursor transitions with remote control disabled from start +/// Expected behavior: +/// - Move: pointy finger (remote control disabled) +/// - Click: noop (no action) +/// - Move: pointy finger +/// - Enable animation: pointy finger with animation enabled +/// - Move: pointy finger +/// - Click: click animation plays (but doesn't take control) +/// - Disable animation: back to pointy finger +/// - Move: pointy finger +/// - Click: noop (no action) +pub async fn test_transitions_remote_disabled_with_animation() -> io::Result<()> { + println!("\n=== TEST: Remote Control Disabled - Animation Toggling ==="); + let (mut cursor_socket, _) = screenshare_client::start_screenshare_session()?; + + // Disable remote control before connecting + cursor_socket.send_message(Message::ControllerCursorEnabled(false))?; + + let url = std::env::var("LIVEKIT_URL").expect("LIVEKIT_URL environment variable not set"); + let token = livekit_utils::generate_token("TestUser"); + let (room, _rx) = Room::connect(&url, &token, RoomOptions::default()) + .await + .unwrap(); + + println!("Participant connected. Waiting for textures..."); + tokio::time::sleep(std::time::Duration::from_secs(7)).await; + + let mut x = 0.3; + let y = 0.5; + let step = 0.15; + + // Move (pointy finger) + println!("\n1. Move - Expected: pointy finger (remote control disabled)"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (noop) + println!("\n2. Click - Expected: noop (no action)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n3. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Enable animation + println!("\n4. Enable animation - Expected: pointy finger with animation enabled"); + send_click_animation(&room, true).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n5. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (animation plays but doesn't take control) + println!("\n6. Click - Expected: click animation plays (but doesn't take control)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Disable animation + println!("\n7. Disable animation - Expected: back to pointy finger"); + send_click_animation(&room, false).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n8. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (noop) + println!("\n9. Click - Expected: noop (no action)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + println!("\n=== TEST COMPLETED ==="); + screenshare_client::stop_screenshare_session(&mut cursor_socket)?; + Ok(()) +} + +/// Test cursor transitions with remote control toggling and animation +/// Expected behavior: +/// - Move: normal cursor (no control) +/// - Click: take control +/// - Move: give control back -> pointy finger +/// - Enable animation: pointy finger with animation +/// - Move: pointy finger +/// - Click: click animation plays +/// - Disable remote control: pointy finger (clicks won't work) +/// - Move: pointy finger +/// - Click: noop (animation doesn't play, no control taken) +/// - Disable animation: pointy finger +/// - Move: pointy finger +/// - Click: noop (no action) +pub async fn test_transitions_mixed_remote_and_animation() -> io::Result<()> { + println!("\n=== TEST: Mixed Remote Control and Animation Transitions ==="); + let (mut cursor_socket, _) = screenshare_client::start_screenshare_session()?; + + let url = std::env::var("LIVEKIT_URL").expect("LIVEKIT_URL environment variable not set"); + let token = livekit_utils::generate_token("TestUser"); + let (room, _rx) = Room::connect(&url, &token, RoomOptions::default()) + .await + .unwrap(); + + println!("Participant connected. Waiting for textures..."); + tokio::time::sleep(std::time::Duration::from_secs(7)).await; + + let mut x = 0.1; + let y = 0.6; + let step = 0.12; + + // Move (normal cursor) + println!("\n1. Move - Expected: normal cursor (no control)"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (take control) + println!("\n2. Click - Expected: take control"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (give control, pointy finger) + println!("\n3. Move - Expected: give control back -> pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Enable animation + println!("\n4. Enable animation - Expected: pointy finger with animation"); + send_click_animation(&room, true).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n5. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (animation plays) + println!("\n6. Click - Expected: click animation plays"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Disable remote control + println!("\n7. Disable ControllerCursorEnabled - Expected: pointy finger (clicks won't work)"); + cursor_socket.send_message(Message::ControllerCursorEnabled(false))?; + sleep(Duration::from_millis(1000)).await; + + // Click (noop - animation doesn't play, no control) + println!("\n8. Click - Expected: noop (animation doesn't play, no control taken)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Disable remote control + println!("\n9. Enable ControllerCursorEnabled - Expected: pointy finger (clicks won't work)"); + cursor_socket.send_message(Message::ControllerCursorEnabled(true))?; + sleep(Duration::from_millis(1000)).await; + + println!("\n10. Click - Expected: click animation plays"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n11. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Disable animation + println!("\n12. Disable animation - Expected: pointy finger"); + send_click_animation(&room, false).await?; + sleep(Duration::from_millis(1000)).await; + + // Move (pointy finger) + println!("\n13. Move - Expected: pointy finger"); + smooth_cursor_move(&room, x, y, x + step, y).await?; + x += step; + sleep(Duration::from_millis(1000)).await; + + // Click (noop) + println!("\n14. Click - Expected: noop (no action)"); + send_mouse_click(&room, x, y, 0).await?; + sleep(Duration::from_millis(1000)).await; + + println!("\n=== TEST COMPLETED ==="); + screenshare_client::stop_screenshare_session(&mut cursor_socket)?; + Ok(()) +} diff --git a/tauri/src/components/SharingScreen/Controls.tsx b/tauri/src/components/SharingScreen/Controls.tsx index 8cea512..4942727 100644 --- a/tauri/src/components/SharingScreen/Controls.tsx +++ b/tauri/src/components/SharingScreen/Controls.tsx @@ -4,12 +4,11 @@ import { useSharingContext } from "@/windows/screensharing/context"; import { TooltipContent, TooltipTrigger, Tooltip, TooltipProvider } from "../ui/tooltip"; import { BiSolidJoystick } from "react-icons/bi"; import useStore from "@/store/store"; -import { SegmentedControl, ClickAnimationButton } from "../ui/segmented-control"; +import { SegmentedControl } from "../ui/segmented-control"; import { useState } from "react"; export function ScreenSharingControls() { - const { setIsSharingKeyEvents, setIsSharingMouse, clickAnimationEnabled, setClickAnimationEnabled } = - useSharingContext(); + const { setIsSharingKeyEvents, setIsSharingMouse } = useSharingContext(); const isRemoteControlEnabled = useStore((state) => state.callTokens?.isRemoteControlEnabled); const [remoteControlStatus, setRemoteControlStatus] = useState("controlling"); @@ -24,10 +23,6 @@ export function ScreenSharingControls() { } }; - const handleClickAnimationToggle = () => { - setClickAnimationEnabled(!clickAnimationEnabled); - }; - return (
@@ -50,7 +45,6 @@ export function ScreenSharingControls() { onValueChange={handleRemoteControlChange} className="pointer-events-auto" /> -
{isRemoteControlEnabled === false && ( diff --git a/tauri/src/components/SharingScreen/SharingScreen.tsx b/tauri/src/components/SharingScreen/SharingScreen.tsx index 9bbde13..58c575c 100644 --- a/tauri/src/components/SharingScreen/SharingScreen.tsx +++ b/tauri/src/components/SharingScreen/SharingScreen.tsx @@ -10,7 +10,6 @@ import { useSharingContext } from "@/windows/screensharing/context"; import { useResizeListener } from "@/lib/hooks"; import { cn, getAbsolutePosition, getRelativePosition } from "@/lib/utils"; import { - TPClickAnimation, TPKeystroke, TPMouseClick, TPMouseMove, @@ -80,7 +79,7 @@ const ConsumerComponent = React.memo(() => { onlySubscribed: true, }); const localParticipant = useLocalParticipant(); - let { isSharingMouse, isSharingKeyEvents, parentKeyTrap, clickAnimationEnabled } = useSharingContext(); + let { isSharingMouse, isSharingKeyEvents, parentKeyTrap } = useSharingContext(); const [wrapperRef, isMouseInside] = useHover(); const { updateCallTokens } = useStore(); const [mouse, mouseRef] = useMouse(); @@ -357,26 +356,18 @@ const ConsumerComponent = React.memo(() => { if (videoElement) { const payload: TPMouseVisible = { type: "MouseVisible", - payload: { visible: isSharingMouse }, - }; - localParticipant.localParticipant?.publishData(encoder.encode(JSON.stringify(payload)), { reliable: true }); - } - - if (videoElement) { - const payload: TPClickAnimation = { - type: "ClickAnimation", - payload: { enabled: clickAnimationEnabled }, + payload: { visible: !isSharingMouse }, }; localParticipant.localParticipant?.publishData(encoder.encode(JSON.stringify(payload)), { reliable: true }); } if (videoElement) { videoElement.addEventListener("mousemove", handleMouseMove); + videoElement.addEventListener("mousedown", handleMouseDown); } if (videoElement && isSharingMouse) { videoElement.addEventListener("wheel", handleWheel); - videoElement.addEventListener("mousedown", handleMouseDown); videoElement.addEventListener("mouseup", handleMouseUp); videoElement.addEventListener("contextmenu", handleContextMenu); } @@ -390,7 +381,7 @@ const ConsumerComponent = React.memo(() => { videoElement.removeEventListener("contextmenu", handleContextMenu); } }; - }, [isSharingMouse, updateMouseControls, clickAnimationEnabled]); + }, [isSharingMouse, updateMouseControls]); /** * Keyboard sharing logic diff --git a/tauri/src/components/ui/segmented-control.tsx b/tauri/src/components/ui/segmented-control.tsx index 5a98e77..e9cf44a 100644 --- a/tauri/src/components/ui/segmented-control.tsx +++ b/tauri/src/components/ui/segmented-control.tsx @@ -4,7 +4,6 @@ import * as React from "react"; import { motion, LayoutGroup } from "framer-motion"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; -import { HiOutlineCursorArrowRipple } from "react-icons/hi2"; interface SegmentedControlItem { id: string; @@ -144,49 +143,5 @@ const SegmentedControl = React.forwardRef void; -} - -const ClickAnimationButton = React.forwardRef( - ({ enabled, onToggle }, ref) => { - return ( - - - -
- {enabled && ( - - )} - - - -
-
-
- Highlight clicks -
- ); - }, -); - -ClickAnimationButton.displayName = "ClickAnimationButton"; - -export { SegmentedControl, ClickAnimationButton }; -export type { SegmentedControlProps, SegmentedControlItem, ClickAnimationButtonProps }; +export { SegmentedControl }; +export type { SegmentedControlProps, SegmentedControlItem }; diff --git a/tauri/src/payloads.ts b/tauri/src/payloads.ts index 2c6de4a..6e72c17 100644 --- a/tauri/src/payloads.ts +++ b/tauri/src/payloads.ts @@ -70,14 +70,6 @@ export const PKeystroke = z.object({ }); export type TPKeystroke = z.infer; -export const PClickAnimation = z.object({ - type: z.literal("ClickAnimation"), - payload: z.object({ - enabled: z.boolean(), - }), -}); -export type TPClickAnimation = z.infer; - // WebSocket Message Types export const MessageType = z.enum([ "success", diff --git a/tauri/src/windows/screensharing/context.tsx b/tauri/src/windows/screensharing/context.tsx index 77a61ab..4ba68cd 100644 --- a/tauri/src/windows/screensharing/context.tsx +++ b/tauri/src/windows/screensharing/context.tsx @@ -3,11 +3,9 @@ import React, { createContext, useContext, useState, ReactNode } from "react"; type SharingContextType = { isSharingMouse: boolean; isSharingKeyEvents: boolean; - clickAnimationEnabled: boolean; videoToken: string | null; setIsSharingMouse: (value: boolean) => void; setIsSharingKeyEvents: (value: boolean) => void; - setClickAnimationEnabled: (value: boolean) => void; setVideoToken: (value: string) => void; parentKeyTrap?: HTMLDivElement; setParentKeyTrap: (value: HTMLDivElement) => void; @@ -30,7 +28,6 @@ type SharingProviderProps = { export const SharingProvider: React.FC = ({ children }) => { const [isSharingMouse, setIsSharingMouse] = useState(true); const [isSharingKeyEvents, setIsSharingKeyEvents] = useState(true); - const [clickAnimationEnabled, setClickAnimationEnabled] = useState(false); const [parentKeyTrap, setParentKeyTrap] = useState(undefined); const [videoToken, setVideoToken] = useState(null); @@ -39,10 +36,8 @@ export const SharingProvider: React.FC = ({ children }) => value={{ isSharingMouse, isSharingKeyEvents, - clickAnimationEnabled, setIsSharingMouse, setIsSharingKeyEvents, - setClickAnimationEnabled, parentKeyTrap, setParentKeyTrap, videoToken, From 5488ec8aca301f6fcdeb452f314c6530db0c0ae9 Mon Sep 17 00:00:00 2001 From: konsalex Date: Thu, 9 Oct 2025 21:17:06 +0200 Subject: [PATCH 6/7] chore: pointer icon and click animation in controller --- tauri/src/App.css | 25 +++++++++++++++++++ .../src/components/SharingScreen/Controls.tsx | 3 ++- .../SharingScreen/SharingScreen.tsx | 20 +++++++++++++++ tauri/src/components/ui/icons.tsx | 24 ++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/tauri/src/App.css b/tauri/src/App.css index f7e0bbe..09d3cf8 100644 --- a/tauri/src/App.css +++ b/tauri/src/App.css @@ -466,6 +466,31 @@ body { animation: oscillate 0.5s ease-in-out infinite; } +@keyframes click-ripple-effect { + 0% { + opacity: 1; + transform: scale(0.8); + } + 50% { + opacity: 0.8; + transform: scale(1.2); + } + 100% { + opacity: 0; + transform: scale(1.6); + } +} + +.click-ripple { + position: fixed; + width: 10px; + height: 10px; + background-color: transparent; + border-radius: 50%; + border: 2px solid #3b82f6; + pointer-events: none; +} + /* Hack to make this feel native app */ * { -webkit-user-select: none; diff --git a/tauri/src/components/SharingScreen/Controls.tsx b/tauri/src/components/SharingScreen/Controls.tsx index 4942727..42417f1 100644 --- a/tauri/src/components/SharingScreen/Controls.tsx +++ b/tauri/src/components/SharingScreen/Controls.tsx @@ -6,6 +6,7 @@ import { BiSolidJoystick } from "react-icons/bi"; import useStore from "@/store/store"; import { SegmentedControl } from "../ui/segmented-control"; import { useState } from "react"; +import { CustomIcons } from "../ui/icons"; export function ScreenSharingControls() { const { setIsSharingKeyEvents, setIsSharingMouse } = useSharingContext(); @@ -37,7 +38,7 @@ export function ScreenSharingControls() { }, { id: "pointing", - content: , + content: , tooltipContent: "Pointing", }, ]} diff --git a/tauri/src/components/SharingScreen/SharingScreen.tsx b/tauri/src/components/SharingScreen/SharingScreen.tsx index 58c575c..c34ce0b 100644 --- a/tauri/src/components/SharingScreen/SharingScreen.tsx +++ b/tauri/src/components/SharingScreen/SharingScreen.tsx @@ -209,6 +209,21 @@ const ConsumerComponent = React.memo(() => { return () => clearInterval(interval); }, []); + // Apply cursor ripple effect function + const applyCursorRippleEffect = (e: MouseEvent) => { + const ripple = document.createElement("div"); + + ripple.className = "click-ripple"; + document.body.appendChild(ripple); + + ripple.style.left = `${e.clientX - 10}px`; + ripple.style.top = `${e.clientY - 10}px`; + ripple.style.animation = "click-ripple-effect 0.8s ease-out forwards"; + ripple.onanimationend = () => { + document.body.removeChild(ripple); + }; + }; + /** * Currently returning the last screen share track * If there are multiple screen share tracks, and some are "white" @@ -284,6 +299,11 @@ const ConsumerComponent = React.memo(() => { const { relativeX, relativeY } = getRelativePosition(videoElement, e); // console.debug(`Clicking down 🖱️: relativeX: ${relativeX}, relativeY: ${relativeY}, detail ${e.detail}`); + // Add click pulse when NOT sharing mouse (pointing mode) + if (!isSharingMouse) { + applyCursorRippleEffect(e); + } + const payload: TPMouseClick = { type: "MouseClick", payload: { diff --git a/tauri/src/components/ui/icons.tsx b/tauri/src/components/ui/icons.tsx index 95c5d5e..bf1ee80 100644 --- a/tauri/src/components/ui/icons.tsx +++ b/tauri/src/components/ui/icons.tsx @@ -31,9 +31,33 @@ const CornerIcon = (props: SVGProps) => ( ); +const PointerClickIcon = (props: SVGProps) => ( + + + + + + + + + + + +); + const CustomIcons = { Drag: DragIcon, Corner: CornerIcon, + PointerClick: PointerClickIcon, }; export { CustomIcons }; From 4c0f2b0d866d26264d4a0d6e545230a9fa78d97d Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Fri, 10 Oct 2025 08:06:26 +0100 Subject: [PATCH 7/7] chore: minor fixes --- core/src/graphics/click_animation.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/core/src/graphics/click_animation.rs b/core/src/graphics/click_animation.rs index 3c9feb5..aa660e0 100644 --- a/core/src/graphics/click_animation.rs +++ b/core/src/graphics/click_animation.rs @@ -143,6 +143,8 @@ impl ClickAnimation { let elapsed = enabled_instant.elapsed().as_millis(); let time_offset = 300; if elapsed > time_offset { + // We want the radius to reach up to 0.1 + 0.3. We got + // 2333 by (1000 - 300) / 0.3. let radius = radius_start + (elapsed - time_offset) as f32 / 2333.0; self.update_radius(queue, radius_buffer, radius); } @@ -193,9 +195,9 @@ pub struct ClickAnimationRenderer { /// Bind group for accessing the radius buffer pub radius_bind_group: wgpu::BindGroup, /// Sender for communicating animation enable requests to the render thread - pub clik_animation_position_sender: std::sync::mpsc::Sender, + pub click_animation_position_sender: std::sync::mpsc::Sender, /// Receiver for animation enable requests (only accessed from render thread) - pub clik_animation_position_receiver: std::sync::mpsc::Receiver, + pub click_animation_position_receiver: std::sync::mpsc::Receiver, /// Array of all click animation instances pub click_animations: Vec, /// Queue of available (inactive) animation slots @@ -346,7 +348,6 @@ impl ClickAnimationRenderer { count: None, }], }); - log::info!("aligned_radius_buffer_size: {}", aligned_radius_buffer_size); let radius_whole_buffer_size = aligned_radius_buffer_size * MAX_ANIMATIONS as wgpu::BufferAddress; let radius_buffer = device.create_buffer(&wgpu::BufferDescriptor { @@ -469,8 +470,8 @@ impl ClickAnimationRenderer { radius_buffer, radius_buffer_entry_offset: aligned_radius_buffer_size, radius_bind_group, - clik_animation_position_sender: sender, - clik_animation_position_receiver: receiver, + click_animation_position_sender: sender, + click_animation_position_receiver: receiver, click_animations, available_slots, used_slots: VecDeque::new(), @@ -643,7 +644,7 @@ impl ClickAnimationRenderer { /// where it will be processed on the next draw call. This allows animations /// to be triggered from any thread safely. pub fn enable_click_animation(&mut self, position: Position) { - if let Err(e) = self.clik_animation_position_sender.send(position) { + if let Err(e) = self.click_animation_position_sender.send(position) { log::error!("enable_click_animation: error sending position: {e:?}"); } } @@ -664,7 +665,7 @@ impl ClickAnimationRenderer { /// them to the available pool once they complete. pub fn draw(&mut self, render_pass: &mut wgpu::RenderPass, queue: &wgpu::Queue) { // Drain click animation enable requests. - while let Ok(position) = self.clik_animation_position_receiver.try_recv() { + while let Ok(position) = self.click_animation_position_receiver.try_recv() { if self.available_slots.is_empty() { log::warn!("enable_click_animation: available_slots is empty"); break;