diff --git a/core/AGENTS.md b/core/AGENTS.md index 97a0d839..2deb6510 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -26,10 +26,39 @@ This directory contains the Rust screen capture and remote control engine (`hopp ## Conventions & Structure - Platform-specific modules live in `src/**/{linux,macos,windows}.rs`. +- Platform modules use `#[cfg_attr(target_os = "macos", path = "macos.rs")] mod platform;` to conditionally select the platform file at compile time. - Core subsystems: `capture/`, `graphics/`, `input/`, `room_service/`. - Logging uses `env_logger`; set `RUST_LOG=hopp_core=info` (use `debug` only when needed). +## Architecture Overview + +- Screen-sharing and remote-control engine: one user **shares** their screen, others **control** it remotely. +- Launched as a child process by a Tauri app; communicates with it via IPC socket (`socket_lib`). +- Main event loop is driven by `winit`; rendering uses `wgpu`. +- `RoomService` runs an async Tokio runtime managing a LiveKit/WebRTC room for media and data channels. +- Two roles: **Sharer** (captures screen, forwards remote input) and **Controller** (renders remote frames, sends input). +- CLI args: `--textures-path`, `--sentry-dsn`, `--socket-path`. + +## Supporting Crates + +- `socket_lib` — IPC layer between the Tauri app and the core process (Unix sockets on macOS/Linux, TCP on Windows). Defines all message types exchanged between the two processes. +- `sentry_utils` — Error reporting and telemetry via Sentry. + +## Key Types + +- `Application` — central state struct, owns all subsystems. +- `RenderEventLoop` — wraps the `winit` event loop and drives rendering. +- `UserEvent` — enum of all events flowing through the system (socket messages, room events, UI actions). +- `RemoteControl` — active session state holding graphics and input controllers. +- `RoomService` — manages the LiveKit/WebRTC room on an async Tokio runtime. + ## Related Docs - `README.md` for architecture and diagrams. - `tests/README.md` for test setup and commands (manual only). + +## Plan Mode + +- Make the plan extremely concise. Sacrifice grammar for the sake of concision. +- At the end of each plan, give me a list of unresolved questions to answer, if any. + diff --git a/core/Cargo.lock b/core/Cargo.lock index d0a06c1a..2747d6b0 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -2463,6 +2463,7 @@ dependencies = [ "iced_graphics", "kurbo 0.10.4", "log", + "resvg", "rustc-hash 2.1.1", "softbuffer", "tiny-skia", @@ -2484,6 +2485,7 @@ dependencies = [ "iced_graphics", "log", "lyon", + "resvg", "rustc-hash 2.1.1", "thiserror 2.0.17", "wgpu", diff --git a/core/Cargo.toml b/core/Cargo.toml index 3f209c21..0881d957 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -70,5 +70,5 @@ fontdb = "0.23.0" arboard = "3.6.1" iced_winit = "0.14.0" iced_wgpu = "0.14.0" -iced = { version = "0.14.0", features = ["canvas", "image"] } +iced = { version = "0.14.0", features = ["canvas", "image", "svg"] } iced_core = "0.14.0" diff --git a/core/src/graphics/click_animation.rs b/core/src/graphics/click_animation.rs index d4e2bc7a..2d3e18c6 100644 --- a/core/src/graphics/click_animation.rs +++ b/core/src/graphics/click_animation.rs @@ -1,718 +1,149 @@ -//! 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::clock::Clock; +use crate::utils::geometry::Position; +use iced::widget::canvas::{Frame, Geometry, Path, Stroke}; +use iced::{Color, Rectangle, Renderer}; +use std::collections::VecDeque; +use std::sync::Arc; -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; - pub const ANIMATION_DURATION: u64 = 1000; +const RING_DELAY_MS: u128 = 300; + +/// Color #B91801 +const CLICK_COLOR: Color = Color { + r: 0.725, + g: 0.094, + b: 0.004, + a: 1., +}; + +const INITIAL_RADIUS: f32 = 5.0; +const MAX_RADIUS_GROWTH: f32 = 30.0; -/// 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 +struct ClickAnimation { + position: Position, enabled_instant: Option, + clock: Arc, } 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()); + fn enable(&mut self, position: Position) { + self.position = position; + self.enabled_instant = Some(self.clock.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); + fn disable(&mut self) { 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 { - // 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); - } - if elapsed > ANIMATION_DURATION.into() { - 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 click_animation_position_sender: std::sync::mpsc::Sender, - /// Receiver for animation enable requests (only accessed from render thread) - 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 - 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, + click_animation_position_sender: std::sync::mpsc::Sender, + click_animation_position_receiver: std::sync::mpsc::Receiver, + click_animations: Vec, + available_slots: VecDeque, + used_slots: VecDeque, + clock: Arc, } 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: &str, - 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, - }], - }); - 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: &[], + pub fn new(clock: Arc) -> Self { + let mut click_animations = Vec::with_capacity(MAX_ANIMATIONS); + let mut available_slots = VecDeque::with_capacity(MAX_ANIMATIONS); + for _ in 0..MAX_ANIMATIONS { + click_animations.push(ClickAnimation { + position: Position { x: 0.0, y: 0.0 }, + enabled_instant: None, + clock: clock.clone(), }); - - 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.to_owned(), - 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); + available_slots.push_back(click_animations.len() - 1); } 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, + Self { click_animation_position_sender: sender, click_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); + clock, } - - // 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.click_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. + pub fn update(&mut self) { + // Drain pending enable requests 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; } - 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, - ); + self.click_animations[slot].enable(position); } - 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, - ); + // Disable expired animations and reclaim slots + let now = self.clock.now(); + while let Some(&front) = self.used_slots.front() { + let anim = &self.click_animations[front]; + let expired = match anim.enabled_instant { + Some(instant) => { + now.duration_since(instant).as_millis() > ANIMATION_DURATION.into() + } + None => true, + }; + if expired { + self.click_animations[front].disable(); + self.used_slots.pop_front(); + self.available_slots.push_back(front); + } else { + break; + } } + } - loop { - let front = self.used_slots.front(); - if front.is_none() { - break; + pub fn draw(&self, renderer: &Renderer, bounds: Rectangle) -> Geometry { + let mut frame = Frame::new(renderer, bounds.size()); + let now = self.clock.now(); + for slot in &self.used_slots { + let anim = &self.click_animations[*slot]; + let instant = match anim.enabled_instant { + Some(i) => i, + None => continue, + }; + + let elapsed = now.duration_since(instant).as_millis(); + if elapsed > ANIMATION_DURATION.into() { + continue; } - 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); + let x = anim.position.x as f32; + let y = anim.position.y as f32; + + if elapsed <= RING_DELAY_MS { + // Filled circle phase + let circle = Path::circle(iced::Point::new(x, y), INITIAL_RADIUS); + frame.fill(&circle, CLICK_COLOR); } else { - break; + // Expanding ring phase + let t = (elapsed - RING_DELAY_MS) as f32 + / (ANIMATION_DURATION as f32 - RING_DELAY_MS as f32); + let radius = INITIAL_RADIUS + MAX_RADIUS_GROWTH * t; + let circle = Path::circle(iced::Point::new(x, y), radius); + frame.stroke( + &circle, + Stroke::default().with_color(CLICK_COLOR).with_width(2.0), + ); } } + frame.into_geometry() } } diff --git a/core/src/graphics/cursor.rs b/core/src/graphics/cursor.rs index af4633c0..2f6b7ea2 100644 --- a/core/src/graphics/cursor.rs +++ b/core/src/graphics/cursor.rs @@ -1,436 +1,111 @@ -//! Cursor rendering system for overlay graphics. +//! Image-based cursor for iced canvas rendering. //! -//! This module provides a GPU-accelerated cursor rendering system using wgpu. -//! It supports multiple cursors with individual textures, transforms, and positions. -//! The system uses a shared transform buffer with dynamic offsets for efficient -//! rendering of multiple cursors. - -use crate::utils::geometry::Extent; -use wgpu::util::DeviceExt; - -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; -/// Base horizontal offset for cursor positioning (as a fraction of screen space) -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; +//! This module provides a `Cursor` that renders user cursors as pre-rendered +//! PNG images on an iced canvas frame, using `svg_renderer::render_user_badge_to_png` +//! to convert SVGs to PNGs at construction time. + +use crate::utils::geometry::Position; +use crate::utils::svg_renderer::{render_user_badge_to_png, SvgRenderError}; +use iced::widget::canvas::Frame; +use iced::Rectangle; + +/// Cursor display mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorMode { + /// Normal arrow cursor + Normal, + /// Pointer/hand cursor + Pointer, +} -/// Represents a single cursor with its texture, geometry, and position data. -/// -/// Each cursor maintains its own vertex and index buffers for geometry, -/// a texture for appearance, and position information for rendering. -/// The cursor uses a dynamic offset into a shared transform buffer. +/// An image-based cursor for rendering on iced canvas frames. #[derive(Debug)] pub struct Cursor { - /// The cursor's texture (image) - texture: Texture, - /// GPU buffer containing vertex data for the cursor quad - vertex_buffer: wgpu::Buffer, - /// GPU buffer containing index data for the cursor quad - index_buffer: wgpu::Buffer, - /// Dynamic offset into the shared transform buffer - transform_offset: wgpu::DynamicOffset, - /// Position and transformation data - position: Point, + /// Visible name displayed on the cursor + visible_name: String, + /// (handle, (width, height)) for normal arrow cursor + normal_cursor: (iced_core::image::Handle, (f32, f32)), + /// (handle, (width, height)) for pointer/hand cursor + pointer_cursor: (iced_core::image::Handle, (f32, f32)), + /// Current position of the cursor + position: Option, + /// Current cursor display mode + mode: CursorMode, } impl Cursor { - /// Updates the cursor's position. - /// - /// # Arguments - /// * `x` - New X coordinate (0.0 to 1.0, representing screen space) - /// * `y` - New Y coordinate (0.0 to 1.0, representing screen space) - pub fn set_position(&mut self, x: f64, y: f64) { - self.position.set_position(x as f32, y as f32); - } + /// Creates a new `Cursor` with the given color and name. + pub fn new(color: &str, name: &str) -> Result { + let normal_png = render_user_badge_to_png(color, name, false)?; + let pointer_png = render_user_badge_to_png(color, name, true)?; + + let normal_dims = image::load_from_memory(&normal_png).map_err(|e| { + SvgRenderError::PngSaveError(format!("Failed to read PNG dimensions: {e}")) + })?; + let pointer_dims = image::load_from_memory(&pointer_png).map_err(|e| { + SvgRenderError::PngSaveError(format!("Failed to read PNG dimensions: {e}")) + })?; + + let normal_cursor = ( + iced_core::image::Handle::from_bytes(normal_png), + ( + normal_dims.width() as f32 / 2.5, + normal_dims.height() as f32 / 2.5, + ), + ); + let pointer_cursor = ( + iced_core::image::Handle::from_bytes(pointer_png), + ( + pointer_dims.width() as f32 / 2.5, + pointer_dims.height() as f32 / 2.5, + ), + ); - /// Returns the current transformation matrix for this cursor. - /// - /// This matrix can be used to position the cursor in 3D space or - /// for other transformation calculations. - pub fn get_translation_matrix(&self) -> TransformMatrix { - self.position.get_transform_matrix() + Ok(Self { + visible_name: name.to_string(), + normal_cursor, + pointer_cursor, + position: None, + mode: CursorMode::Normal, + }) } - /// Updates the GPU transform buffer with this cursor's current position. - /// - /// # Arguments - /// * `gfx` - Graphics context containing the shared transform buffer - /// - /// This method uploads the cursor's transformation matrix to the GPU - /// at the appropriate offset in the shared buffer. - pub fn update_transform_buffer(&self, gfx: &GraphicsContext) { - gfx.queue.write_buffer( - &gfx.cursor_renderer.transforms_buffer, - self.transform_offset as wgpu::BufferAddress, - bytemuck::cast_slice(&[self.position.get_transform_matrix()]), - ); + /// Returns the visible name displayed on this cursor. + pub fn visible_name(&self) -> &str { + &self.visible_name } - /// Renders this cursor using the provided render pass. - /// - /// # Arguments - /// * `render_pass` - Active wgpu render pass for drawing - /// * `gfx` - Graphics context containing shared rendering resources - /// - /// This method sets up the necessary bind groups, buffers, and draw call - /// to render the cursor to the current render target. - pub fn draw(&self, render_pass: &mut wgpu::RenderPass, gfx: &GraphicsContext) { - render_pass.set_bind_group(0, &self.texture.bind_group, &[]); - render_pass.set_bind_group( - 1, - &gfx.cursor_renderer.transforms_bind_group, - &[self.transform_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); + /// Sets the cursor display mode. + pub fn set_mode(&mut self, mode: CursorMode) { + self.mode = mode; } -} - -/// Main cursor rendering system that manages multiple cursors. -/// -/// This renderer creates and manages the GPU resources needed for cursor rendering, -/// including shaders, pipelines, and shared buffers. It uses a single transform -/// buffer with dynamic offsets to efficiently handle multiple cursors. -/// -/// # Design Notes -/// -/// Due to compatibility issues with development Windows VMs, this implementation -/// uses a shared transform buffer with dynamic offsets rather than separate -/// buffers for each cursor. -#[derive(Debug)] -pub struct CursorsRenderer { - /// GPU render pipeline for cursor rendering - pub render_pipeline: wgpu::RenderPipeline, - /// Bind group layout for cursor 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 cursor 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, - /// Number of cursors that have been created - pub cursors_created: u32, -} - -impl CursorsRenderer { - /// Creates a new cursor renderer with all necessary GPU resources. - /// - /// # Arguments - /// * `device` - wgpu device for creating GPU resources - /// * `texture_format` - Format of the render target texture - /// - /// # Returns - /// A fully initialized cursor renderer ready to create and render cursors. - /// - /// This method sets up: - /// - Bind group layouts for textures and transforms - /// - A shared transform buffer with proper alignment - /// - Render pipeline with vertex and fragment shaders - /// - All necessary GPU state for cursor rendering - pub fn create(device: &wgpu::Device, texture_format: wgpu::TextureFormat) -> Self { - // Create bind group layout for cursor textures - let texture_bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Shared Cursor 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 cursor, we are using a single transform buffer for all cursors - * 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 cursors - let transforms_buffer_size = aligned_buffer_size * MAX_CURSORS 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), - }), - }], - }); - - // 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"), - bind_group_layouts: &[&texture_bind_group_layout, &transform_bind_group_layout], - push_constant_ranges: &[], - }); - - let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Render Pipeline"), - layout: Some(&render_pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_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_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, - }); - 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, - cursors_created: 0, - } + /// Sets the cursor position. + pub fn set_position(&mut self, position: Option) { + self.position = position; } - /// Creates a new cursor with the specified image and properties. - /// - /// # Arguments - /// * `image_data` - Loaded image data - /// * `scale` - Display scale - /// * `device` - wgpu device for creating GPU resources - /// * `queue` - wgpu queue for uploading data to GPU - /// * `texture_path` - texture path - /// * `window_size` - Size of the rendering window for proper scaling - /// - /// # Returns - /// A new `Cursor` instance ready for rendering, or an error if creation fails. - /// - /// # Errors - /// Returns `OverlayError::TextureCreationError` if: - /// - The maximum number of cursors has been reached - /// - Texture creation fails - /// - /// The cursor is automatically positioned at (0,0) and its transform matrix - /// is uploaded to the GPU. - pub fn create_cursor( - &mut self, - image_data: &[u8], - scale: f64, - device: &wgpu::Device, - queue: &wgpu::Queue, - window_size: Extent, - ) -> Result { - if self.cursors_created >= MAX_CURSORS { - log::error!("create_cursor: maximum number of cursors reached"); - return Err(OverlayError::TextureCreationError); + /// Draws the cursor onto an iced canvas frame. + pub fn draw(&self, frame: &mut Frame) { + if self.position.is_none() { + return; } - // Create texture from image file - let texture = create_texture(device, queue, image_data, &self.texture_bind_group_layout)?; - - // Create vertex and index buffers for cursor geometry - let (vertex_buffer, index_buffer) = - Self::create_cursor_vertex_buffer(device, &texture, scale, window_size); - - // Calculate offset into shared transform buffer - let transform_offset = - (self.cursors_created as wgpu::BufferAddress) * self.transforms_buffer_entry_offset; - self.cursors_created += 1; - - // Initialize cursor position with base offsets - let point = Point::new( - 0.0, - 0.0, - BASE_OFFSET_X * (scale as f32), - BASE_OFFSET_Y * (scale as f32), - ); - - // Upload initial transform matrix to GPU - queue.write_buffer( - &self.transforms_buffer, - transform_offset, - bytemuck::cast_slice(&[point.get_transform_matrix()]), - ); - - Ok(Cursor { - texture, - vertex_buffer, - index_buffer, - transform_offset: transform_offset as wgpu::DynamicOffset, - position: point, - }) - } - - /// Creates vertex and index buffers for a cursor quad. - /// - /// # Arguments - /// * `device` - wgpu device for creating buffers - /// * `texture` - Cursor texture containing size information - /// * `scale` - Scale factor for cursor size - /// * `window_size` - Window dimensions for proper aspect ratio - /// - /// # Returns - /// A tuple containing (vertex_buffer, index_buffer) for the cursor 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_cursor_vertex_buffer( - device: &wgpu::Device, - texture: &Texture, - scale: f64, - window_size: Extent, - ) -> (wgpu::Buffer, wgpu::Buffer) { - /* - * Here we want to make the cursor size in the shader to always - * be relative to the monitor extents. Also we want to keep the - * original ratio of the texture. - */ - - // Calculate cursor size in clip space, maintaining aspect ratio - let clip_extent = Extent { - width: (texture.extent.width / window_size.width) * 2.0 * scale / 2.5, - height: (texture.extent.height / window_size.height) * 2.0 * scale / 2.5, + let (handle, (width, height)) = match self.mode { + CursorMode::Pointer => &self.pointer_cursor, + CursorMode::Normal => &self.normal_cursor, }; - // 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], + let image = iced_core::image::Image::new(handle.clone()); + let position = self.position.as_ref().unwrap(); + frame.draw_image( + Rectangle { + x: position.x as f32, + y: position.y as f32, + width: *width, + height: *height, }, - 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) + image, + ); } } diff --git a/core/src/graphics/draw.rs b/core/src/graphics/draw.rs index 90a0d4cc..07349832 100644 --- a/core/src/graphics/draw.rs +++ b/core/src/graphics/draw.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::time::{Duration, Instant}; use iced::widget::canvas::{path, stroke, Cache, Frame, Geometry, Stroke}; @@ -239,142 +238,3 @@ impl Draw { Some(builder.build()) } } - -/// Manager that owns Draw objects mapped by participant sid. -/// Each participant gets their own Draw instance with their assigned color. -#[derive(Default)] -pub struct DrawManager { - draws: HashMap, -} - -impl DrawManager { - pub fn new() -> Self { - Self::default() - } - - /// Adds a new participant with their color. - pub fn add_participant(&mut self, sid: String, color: &str, auto_clear: bool) { - log::info!( - "DrawManager::add_participant: sid={} color={} auto_clear={}", - sid, - color, - auto_clear - ); - self.draws.insert(sid, Draw::new(color, auto_clear)); - } - - /// Removes a participant and their drawing data. - pub fn remove_participant(&mut self, sid: &str) { - log::info!("DrawManager::remove_participant: sid={}", sid); - self.draws.remove(sid); - } - - /// Sets the drawing mode for a specific participant. - pub fn set_drawing_mode(&mut self, sid: &str, mode: DrawingMode) { - log::debug!("DrawManager::set_drawing_mode: sid={} mode={:?}", sid, mode); - if let Some(draw) = self.draws.get_mut(sid) { - draw.set_mode(mode); - } else { - log::warn!( - "DrawManager::set_drawing_mode: participant {} not found", - sid - ); - } - } - - /// Starts a new drawing path for a participant. - pub fn draw_start(&mut self, sid: &str, point: Position, path_id: u64) { - log::debug!( - "DrawManager::draw_start: sid={} point={:?} path_id={}", - sid, - point, - path_id - ); - if let Some(draw) = self.draws.get_mut(sid) { - draw.start_path(path_id, point); - } else { - log::warn!("DrawManager::draw_start: participant {} not found", sid); - } - } - - /// Adds a point to the current drawing path for a participant. - pub fn draw_add_point(&mut self, sid: &str, point: Position) { - log::debug!("DrawManager::draw_add_point: sid={} point={:?}", sid, point); - if let Some(draw) = self.draws.get_mut(sid) { - draw.add_point(point); - } else { - log::warn!("DrawManager::draw_add_point: participant {} not found", sid); - } - } - - /// Ends the current drawing path for a participant. - pub fn draw_end(&mut self, sid: &str, point: Position) { - log::debug!("DrawManager::draw_end: sid={} point={:?}", sid, point); - if let Some(draw) = self.draws.get_mut(sid) { - draw.add_point(point); - draw.finish_path(); - } else { - log::warn!("DrawManager::draw_end: participant {} not found", sid); - } - } - - /// Clears a specific drawing path for a participant. - pub fn draw_clear_path(&mut self, sid: &str, path_id: u64) { - log::debug!( - "DrawManager::draw_clear_path: sid={} path_id={}", - sid, - path_id - ); - if let Some(draw) = self.draws.get_mut(sid) { - draw.clear_path(path_id); - } else { - log::warn!( - "DrawManager::draw_clear_path: participant {} not found", - sid - ); - } - } - - /// Clears all drawing paths for a participant. - pub fn draw_clear_all_paths(&mut self, sid: &str) { - log::debug!("DrawManager::draw_clear_all_paths: sid={}", sid); - if let Some(draw) = self.draws.get_mut(sid) { - draw.clear(); - } else { - log::warn!( - "DrawManager::draw_clear_all_paths: participant {} not found", - sid - ); - } - } - - pub fn update_auto_clear(&mut self) -> Vec { - let mut removed_path_ids = Vec::new(); - - for draw in self.draws.values_mut() { - let removed = draw.clear_expired_paths(); - removed_path_ids.extend(removed); - } - - removed_path_ids - } - - /// Renders all draws and returns the geometries. - pub fn draw(&self, renderer: &Renderer, bounds: Rectangle) -> Vec { - let mut geometries = Vec::with_capacity(self.draws.len() + 1); - - // Collect cached completed geometries from each Draw - for draw in self.draws.values() { - geometries.push(draw.draw_completed(renderer, bounds)); - } - - // Draw all in-progress paths into a single frame - let mut in_progress_frame = Frame::new(renderer, bounds.size()); - for draw in self.draws.values() { - draw.draw_in_progress_to_frame(&mut in_progress_frame); - } - geometries.push(in_progress_frame.into_geometry()); - - geometries - } -} diff --git a/core/src/graphics/graphics_context.rs b/core/src/graphics/graphics_context.rs index cda87263..9cf5e006 100644 --- a/core/src/graphics/graphics_context.rs +++ b/core/src/graphics/graphics_context.rs @@ -4,33 +4,78 @@ //! 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::utils::geometry::Extent; -use crate::{input::mouse::CursorController, utils::geometry::Position}; -use image::GenericImageView; -use log::error; -use std::sync::Arc; +use crate::utils::clock::Clock; +use crate::utils::geometry::Position; +use crate::UserEvent; +use std::sync::{ + mpsc::{Receiver, Sender}, + Arc, +}; +use std::thread::JoinHandle; +use std::time::Instant; use thiserror::Error; +use winit::event_loop::EventLoopProxy; use winit::window::Window; #[cfg(target_os = "windows")] use super::direct_composition::DirectComposition; -#[path = "cursor.rs"] -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; - #[path = "iced_renderer.rs"] pub mod iced_renderer; use iced_renderer::IcedRenderer; -#[path = "draw.rs"] -pub mod draw; +#[path = "participant.rs"] +pub mod participant; +use participant::{ParticipantError, ParticipantsManager}; + +pub(crate) enum RedrawThreadCommands { + Activity, + Stop, +} + +fn redraw_thread( + event_loop_proxy: EventLoopProxy, + receiver: Receiver, +) { + let redraw_interval = std::time::Duration::from_millis(16); + let inactivity_timeout = std::time::Duration::from_secs(15); + let mut last_activity_time = Instant::now(); + + loop { + // Check for messages with a timeout equal to the redraw interval + match receiver.recv_timeout(redraw_interval) { + Ok(command) => match command { + RedrawThreadCommands::Stop => break, + RedrawThreadCommands::Activity => { + if last_activity_time.elapsed() < redraw_interval { + continue; + } + last_activity_time = Instant::now(); + } + }, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + log::error!("redraw_thread: channel disconnected"); + break; + } + } + + // Check if we should stop due to inactivity + if last_activity_time.elapsed() > inactivity_timeout { + log::debug!("redraw_thread: stopping due to inactivity"); + continue; + } + + // Send redraw event every 16ms + if let Err(e) = event_loop_proxy.send_event(UserEvent::RequestRedraw) { + log::error!("redraw_thread: error sending redraw event: {e:?}"); + } + } +} /// Errors that can occur during overlay graphics operations. #[derive(Error, Debug)] @@ -54,6 +99,10 @@ pub enum OverlayError { /// Failed to create or load a texture resource. #[error("Failed to create or load texture resource")] TextureCreationError, + + /// Maximum number of participants reached. + #[error("Maximum number of participants reached")] + MaxParticipantsReached, } /// Type alias for Results in overlay graphics operations. @@ -63,38 +112,6 @@ pub enum OverlayError { /// Most graphics operations either succeed completely or fail with an `OverlayError`. pub type OverlayResult = std::result::Result; -/// Internal texture representation for overlay graphics. -/// -/// This struct encapsulates a GPU texture resource along with its metadata -/// and binding information. It stores both the texture's dimensions and the -/// wgpu bind group needed for shader access during rendering. -#[derive(Debug)] -struct Texture { - /// Dimensions of the texture in pixels (width, height) - extent: Extent, - /// wgpu bind group containing texture and sampler resources for shader access - bind_group: wgpu::BindGroup, -} - -/// Vertex data structure for overlay geometry rendering. -/// -/// This struct represents a single vertex in the graphics pipeline, containing -/// both position and texture coordinate information. It's designed to be -/// directly uploaded to GPU vertex buffers for efficient rendering. -/// -/// # Memory Layout -/// -/// The struct uses `#[repr(C)]` to ensure consistent memory layout across -/// platforms, making it safe for direct GPU buffer uploads via bytemuck. -#[repr(C)] -#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -struct Vertex { - /// 2D position in clip space coordinates (range: -1.0 to 1.0) - position: [f32; 2], - /// 2D texture coordinates for sampling (range: 0.0 to 1.0) - texture_coords: [f32; 2], -} - /// Core graphics context for overlay rendering operations. /// /// `GraphicsContext` encapsulates all the necessary GPU resources and state required @@ -111,8 +128,8 @@ struct Vertex { /// # Rendering Pipeline /// /// The graphics context maintains separate renderers for different overlay elements: -/// - Cursor rendering via `CursorsRenderer` for multiple simultaneous cursors -/// - Marker rendering via `MarkerRenderer` for corner boundary indicators +/// - Click animation rendering +/// - Iced-based participant cursors and drawings /// /// # Lifetime /// @@ -122,14 +139,12 @@ struct Vertex { pub struct GraphicsContext<'a> { /// wgpu surface for rendering to the window surface: wgpu::Surface<'a>, - /// GPU logical device for creating resources and submitting commands - device: wgpu::Device, - /// Command queue for submitting GPU operations - queue: wgpu::Queue, + /// GPU logical device — kept alive for wgpu resource lifetime + _device: wgpu::Device, + /// Command queue — kept alive for wgpu resource lifetime + _queue: wgpu::Queue, /// Reference to the overlay window window: Arc, - /// Renderer for cursor graphics with multi-cursor support - cursor_renderer: CursorsRenderer, /// Windows-specific DirectComposition integration for transparent overlays #[cfg(target_os = "windows")] @@ -140,6 +155,16 @@ pub struct GraphicsContext<'a> { /// Renderer for iced graphics iced_renderer: IcedRenderer, + + /// Manager for participant state (drawings and cursors) + participants_manager: ParticipantsManager, + + /// Thread that controls rendering cadence + redraw_thread: Option>, + /// Sender for triggering redraws and animations + redraw_thread_sender: Sender, + /// Clock for time tracking + clock: Arc, } impl<'a> GraphicsContext<'a> { @@ -171,7 +196,29 @@ impl<'a> GraphicsContext<'a> { /// # Platform-Specific Behavior /// /// - **Windows**: Initializes DirectComposition for transparent overlay rendering - pub fn new(window_arc: Arc, texture_path: String, scale: f64) -> OverlayResult { + pub fn new( + window_arc: Arc, + texture_path: String, + scale: f64, + event_loop_proxy: EventLoopProxy, + ) -> OverlayResult { + Self::with_clock( + window_arc, + texture_path, + scale, + event_loop_proxy, + crate::utils::clock::default_clock(), + ) + } + + /// Creates a new graphics context with a custom clock (for testing). + pub fn with_clock( + window_arc: Arc, + texture_path: String, + scale: f64, + event_loop_proxy: EventLoopProxy, + clock: Arc, + ) -> OverlayResult { log::info!("GraphicsContext::new"); let size = window_arc.inner_size(); log::info!("GraphicsContext::new: window size: {size:?}, scale: {scale}"); @@ -274,19 +321,7 @@ impl<'a> GraphicsContext<'a> { window_arc.set_minimized(false); } - let cursor_renderer = CursorsRenderer::create(&device, surface_config.format); - - 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, - )?; + let click_animation_renderer = ClickAnimationRenderer::new(clock.clone()); let iced_renderer = IcedRenderer::new( &device, @@ -297,51 +332,68 @@ impl<'a> GraphicsContext<'a> { &texture_path, ); + let (sender, receiver) = std::sync::mpsc::channel(); + let redraw_thread = Some(std::thread::spawn(move || { + redraw_thread(event_loop_proxy, receiver); + })); + Ok(Self { surface, - device, - queue, + _device: device, + _queue: queue, window: window_arc, - cursor_renderer, #[cfg(target_os = "windows")] _direct_composition: direct_composition, click_animation_renderer, iced_renderer, + participants_manager: ParticipantsManager::default(), + redraw_thread, + redraw_thread_sender: sender, + clock, }) } - /// Creates a new cursor with the specified image and scale factor. - /// - /// This method loads a cursor image from disk and creates all necessary GPU - /// resources for rendering it as part of the overlay. The cursor maintains - /// its original aspect ratio while being scaled appropriately for the target - /// window size. + /// Returns a clone of the redraw thread sender for use by subsystems. /// - /// # Arguments + /// This allows other components (like CursorController and CursorWrapper) + /// to trigger redraws by sending commands to the redraw thread. + pub(crate) fn redraw_sender(&self) -> Sender { + self.redraw_thread_sender.clone() + } + + /// Returns a clone of the clock for use by subsystems. /// - /// * `image_data` - Loaded image data - /// * `display_scale` - Display scale + /// This allows other components (like CursorController) to use the same + /// clock for time-dependent logic. + pub fn clock(&self) -> Arc { + self.clock.clone() + } + + /// Triggers rendering activity. /// - /// # Returns + /// Signals the redraw thread to continue rendering and resets the inactivity timer. + pub fn trigger_render(&self) { + if let Err(e) = self + .redraw_thread_sender + .send(RedrawThreadCommands::Activity) + { + log::error!("GraphicsContext::trigger_render: error sending activity event: {e:?}"); + } + } + + /// Triggers a click animation at the given position. /// - /// Returns a `Result` containing the new `Cursor` instance on success, - /// or an `OverlayError` if cursor creation fails. - pub fn create_cursor( - &mut self, - image_data: &[u8], - display_scale: f64, - ) -> std::result::Result { - let window_size = self.window.inner_size(); - self.cursor_renderer.create_cursor( - image_data, - display_scale, - &self.device, - &self.queue, - Extent { - width: window_size.width as f64, - height: window_size.height as f64, - }, - ) + /// Enables the click animation renderer state and signals rendering activity. + pub fn trigger_click_animation(&mut self, position: Position) { + log::debug!("GraphicsContext::trigger_click_animation: {position:?}"); + self.click_animation_renderer + .enable_click_animation(position); + if let Err(e) = self + .redraw_thread_sender + .send(RedrawThreadCommands::Activity) + { + log::error!("GraphicsContext::trigger_click_animation: error: {e:?}"); + } } /// Renders the current frame with all overlay elements. @@ -351,24 +403,21 @@ impl<'a> GraphicsContext<'a> { /// /// # Arguments /// - /// * `cursor_controller` - Controller managing cursor state and rendering - /// /// # Rendering Pipeline /// /// The draw operation follows this sequence: /// 1. Acquire the current frame buffer from the surface /// 2. Clear the frame buffer with transparent black (0,0,0,0) - /// 3. Set up the cursor rendering pipeline - /// 4. Render all active cursors via the cursor controller - /// 5. Render corner markers for overlay boundaries - /// 6. Submit commands to GPU and present the frame + /// 3. Render click animations + /// 4. Render iced elements (participant cursors and drawings) + /// 5. Submit commands to GPU and present the frame /// /// # Error Handling /// /// 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(&mut self, cursor_controller: &CursorController) { + pub fn draw(&mut self) { let output = match self.surface.get_current_texture() { Ok(output) => output, Err(e) => { @@ -379,84 +428,57 @@ impl<'a> GraphicsContext<'a> { let view = output .texture .create_view(&wgpu::TextureViewDescriptor::default()); - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("cursor encoder"), - }); - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("cursor render pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.0, - }), - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - occlusion_query_set: None, - timestamp_writes: None, - }); - render_pass.set_pipeline(&self.cursor_renderer.render_pipeline); - - cursor_controller.draw(&mut render_pass, self); - - self.click_animation_renderer - .draw(&mut render_pass, &self.queue); - drop(render_pass); - self.queue.submit(std::iter::once(encoder.finish())); + self.click_animation_renderer.update(); - self.iced_renderer.draw(&output, &view); + self.iced_renderer.draw( + &output, + &view, + &self.participants_manager, + &self.click_animation_renderer, + ); self.window.pre_present_notify(); output.present(); } - /// Returns a reference to the underlying overlay window. - /// - /// # Returns - /// + /// Returns a mutable reference to the participants manager for cursor updates. + pub fn participants_manager_mut(&mut self) -> &mut ParticipantsManager { + &mut self.participants_manager + } + /// A reference to the `Window` instance used for overlay rendering. 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); - } - - /// Adds a new participant to the draw manager with their color. + /// Adds a new participant with automatic color assignment. /// /// # Arguments /// * `sid` - Session ID identifying the participant - /// * `color` - Hex color string for the participant's drawings + /// * `name` - Full name of the participant (will be made unique) /// * `auto_clear` - Whether to automatically clear paths after 3 seconds (for local participant) - pub fn add_draw_participant(&mut self, sid: String, color: &str, auto_clear: bool) { - self.iced_renderer - .add_draw_participant(sid, color, auto_clear); + /// + /// # Returns + /// * `Ok(())` - Participant added successfully + /// * `Err(ParticipantError)` - Failed to add participant (e.g., participant already exists) + pub fn add_participant( + &mut self, + sid: String, + name: &str, + auto_clear: bool, + ) -> Result<(), ParticipantError> { + self.participants_manager + .add_participant(sid, name, auto_clear) } - /// Removes a participant from the draw manager. + /// Removes a participant. /// /// # Arguments /// * `sid` - Session ID identifying the participant to remove - pub fn remove_draw_participant(&mut self, sid: &str) { - self.iced_renderer.remove_draw_participant(sid); + pub fn remove_participant(&mut self, sid: &str) { + self.participants_manager.remove_participant(sid); } /// Sets the drawing mode for a specific participant. @@ -465,7 +487,7 @@ impl<'a> GraphicsContext<'a> { /// * `sid` - Session ID identifying the participant /// * `mode` - The drawing mode to set pub fn set_drawing_mode(&mut self, sid: &str, mode: crate::room_service::DrawingMode) { - self.iced_renderer.set_drawing_mode(sid, mode); + self.participants_manager.set_drawing_mode(sid, mode); } /// Starts a new drawing path for a participant. @@ -475,7 +497,7 @@ impl<'a> GraphicsContext<'a> { /// * `point` - Starting point of the path /// * `path_id` - Unique identifier for the drawing path pub fn draw_start(&mut self, sid: &str, point: Position, path_id: u64) { - self.iced_renderer.draw_start(sid, point, path_id); + self.participants_manager.draw_start(sid, point, path_id); } /// Adds a point to the current drawing path for a participant. @@ -484,7 +506,7 @@ impl<'a> GraphicsContext<'a> { /// * `sid` - Session ID identifying the participant /// * `point` - Point to add to the current path pub fn draw_add_point(&mut self, sid: &str, point: Position) { - self.iced_renderer.draw_add_point(sid, point); + self.participants_manager.draw_add_point(sid, point); } /// Ends the current drawing path for a participant. @@ -493,7 +515,7 @@ impl<'a> GraphicsContext<'a> { /// * `sid` - Session ID identifying the participant /// * `point` - Final point of the path pub fn draw_end(&mut self, sid: &str, point: Position) { - self.iced_renderer.draw_end(sid, point); + self.participants_manager.draw_end(sid, point); } /// Clears a specific drawing path for a participant. @@ -502,7 +524,7 @@ impl<'a> GraphicsContext<'a> { /// * `sid` - Session ID identifying the participant /// * `path_id` - Unique identifier for the drawing path to clear pub fn draw_clear_path(&mut self, sid: &str, path_id: u64) { - self.iced_renderer.draw_clear_path(sid, path_id); + self.participants_manager.draw_clear_path(sid, path_id); } /// Clears all drawing paths for a participant. @@ -510,7 +532,7 @@ impl<'a> GraphicsContext<'a> { /// # Arguments /// * `sid` - Session ID identifying the participant pub fn draw_clear_all_paths(&mut self, sid: &str) { - self.iced_renderer.draw_clear_all_paths(sid); + self.participants_manager.draw_clear_all_paths(sid); } /// Updates auto-clear for all participants and returns removed path IDs. @@ -518,116 +540,19 @@ impl<'a> GraphicsContext<'a> { /// # Returns /// A vector of removed path IDs pub fn update_auto_clear(&mut self) -> Vec { - self.iced_renderer.update_auto_clear() + self.participants_manager.update_auto_clear() } } impl Drop for GraphicsContext<'_> { fn drop(&mut self) { + // Stop the redraw thread + if let Some(handle) = self.redraw_thread.take() { + let _ = self.redraw_thread_sender.send(RedrawThreadCommands::Stop); + let _ = handle.join(); + } // This is needed for windows, because otherwise the title bar becomes // visible when a new overlay surface is created. self.window.set_minimized(true); } } - -/// Creates a GPU texture from an image file for overlay rendering. -/// -/// This function loads an image from disk, uploads it to GPU memory, and creates -/// all necessary wgpu resources for texture rendering including samplers and -/// bind groups. The resulting texture is ready for use in overlay rendering pipelines. -/// -/// # Arguments -/// -/// * `device` - wgpu device for creating GPU resources -/// * `queue` - wgpu queue for uploading texture data to GPU -/// * `image_data` - Loaded image data -/// * `bind_group_layout` - wgpu bind group layout for the texture resources -/// -/// # Returns -/// -/// Returns a `Result` containing the created `Texture` on success, or an -/// `OverlayError::TextureCreationError` if any step of texture creation fails. -fn create_texture( - device: &wgpu::Device, - queue: &wgpu::Queue, - image_data: &[u8], - bind_group_layout: &wgpu::BindGroupLayout, -) -> Result { - let diffuse_image = match image::load_from_memory(image_data) { - Ok(image) => image, - Err(_) => { - error!("create_cursor_texture: failed to load image"); - return Err(OverlayError::TextureCreationError); - } - }; - - let diffuse_rgba = diffuse_image.to_rgba8(); - - let dimensions = diffuse_image.dimensions(); - let texture_size = wgpu::Extent3d { - width: dimensions.0, - height: dimensions.1, - depth_or_array_layers: 1, - }; - - let diffuse_texture = device.create_texture(&wgpu::TextureDescriptor { - size: texture_size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, - label: Some("texture"), - view_formats: &[], - }); - - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &diffuse_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &diffuse_rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * dimensions.0), - rows_per_image: Some(dimensions.1), - }, - texture_size, - ); - - let diffuse_texture_view = diffuse_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let diffuse_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - address_mode_u: wgpu::AddressMode::ClampToEdge, - address_mode_v: wgpu::AddressMode::ClampToEdge, - address_mode_w: wgpu::AddressMode::ClampToEdge, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - - let diffuse_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("diffuse_bind_group"), - layout: bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&diffuse_texture_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&diffuse_sampler), - }, - ], - }); - - Ok(Texture { - extent: Extent { - width: dimensions.0 as f64, - height: dimensions.1 as f64, - }, - bind_group: diffuse_bind_group, - }) -} diff --git a/core/src/graphics/iced_canvas.rs b/core/src/graphics/iced_canvas.rs index c7769489..f6ccd23c 100644 --- a/core/src/graphics/iced_canvas.rs +++ b/core/src/graphics/iced_canvas.rs @@ -6,15 +6,16 @@ use iced_wgpu::core::Element; mod marker; use marker::Marker; -use crate::graphics::graphics_context::draw::DrawManager; -use crate::utils::geometry::Position; +use crate::graphics::graphics_context::click_animation::ClickAnimationRenderer; +use crate::graphics::graphics_context::participant::ParticipantsManager; #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] pub enum Message {} pub struct OverlaySurfaceCanvas<'a> { marker: &'a Marker, - draws: &'a DrawManager, + participants: &'a ParticipantsManager, + click_animation_renderer: &'a ClickAnimationRenderer, } impl<'a> std::fmt::Debug for OverlaySurfaceCanvas<'a> { @@ -24,8 +25,16 @@ impl<'a> std::fmt::Debug for OverlaySurfaceCanvas<'a> { } impl<'a> OverlaySurfaceCanvas<'a> { - pub fn new(marker: &'a Marker, draws: &'a DrawManager) -> Self { - Self { marker, draws } + pub fn new( + marker: &'a Marker, + participants: &'a ParticipantsManager, + click_animation_renderer: &'a ClickAnimationRenderer, + ) -> Self { + Self { + marker, + participants, + click_animation_renderer, + } } } @@ -41,65 +50,38 @@ impl<'a, Message> canvas::Program for OverlaySurfaceCanvas<'a> { _cursor: mouse::Cursor, ) -> Vec { let mut geometries = vec![self.marker.draw(renderer, bounds)]; - geometries.extend(self.draws.draw(renderer, bounds)); + geometries.extend(self.participants.draw(renderer, bounds)); + + geometries.push(self.click_animation_renderer.draw(renderer, bounds)); + geometries } } pub struct OverlaySurface { marker: Marker, - draws: DrawManager, } impl OverlaySurface { pub fn new(texture_path: &String) -> Self { let marker = Marker::new(texture_path); - let draws = DrawManager::default(); - Self { marker, draws } + Self { marker } } - pub fn view(&mut self) -> Element<'_, Message, Theme, iced::Renderer> { + pub fn view<'a>( + &'a mut self, + participants: &'a ParticipantsManager, + click_animation_renderer: &'a ClickAnimationRenderer, + ) -> Element<'a, Message, Theme, iced::Renderer> { log::debug!("OverlaySurface::view"); - canvas(OverlaySurfaceCanvas::new(&self.marker, &self.draws)) - .width(Length::Fill) - .height(Length::Fill) - .into() - } - - pub fn update_auto_clear(&mut self) -> Vec { - self.draws.update_auto_clear() - } - - pub fn add_draw_participant(&mut self, sid: String, color: &str, auto_clear: bool) { - self.draws.add_participant(sid, color, auto_clear); - } - - pub fn remove_draw_participant(&mut self, sid: &str) { - self.draws.remove_participant(sid); - } - - pub fn set_drawing_mode(&mut self, sid: &str, mode: crate::room_service::DrawingMode) { - self.draws.set_drawing_mode(sid, mode); - } - - pub fn draw_start(&mut self, sid: &str, point: Position, path_id: u64) { - self.draws.draw_start(sid, point, path_id); - } - - pub fn draw_add_point(&mut self, sid: &str, point: Position) { - self.draws.draw_add_point(sid, point); - } - - pub fn draw_end(&mut self, sid: &str, point: Position) { - self.draws.draw_end(sid, point); - } - - pub fn draw_clear_path(&mut self, sid: &str, path_id: u64) { - self.draws.draw_clear_path(sid, path_id); - } - - pub fn draw_clear_all_paths(&mut self, sid: &str) { - self.draws.draw_clear_all_paths(sid); + canvas(OverlaySurfaceCanvas::new( + &self.marker, + participants, + click_animation_renderer, + )) + .width(Length::Fill) + .height(Length::Fill) + .into() } } diff --git a/core/src/graphics/iced_renderer.rs b/core/src/graphics/iced_renderer.rs index bb585eae..b06e88d5 100644 --- a/core/src/graphics/iced_renderer.rs +++ b/core/src/graphics/iced_renderer.rs @@ -19,7 +19,8 @@ use winit::window::Window; mod iced_canvas; use iced_canvas::OverlaySurface; -use crate::utils::geometry::Position; +use super::click_animation::ClickAnimationRenderer; +use super::participant::ParticipantsManager; pub struct IcedRenderer { renderer: Renderer, @@ -70,9 +71,16 @@ impl IcedRenderer { } } - pub fn draw(&mut self, frame: &wgpu::SurfaceTexture, view: &wgpu::TextureView) { + pub fn draw( + &mut self, + frame: &wgpu::SurfaceTexture, + view: &wgpu::TextureView, + participants: &ParticipantsManager, + click_animation_renderer: &ClickAnimationRenderer, + ) { let mut interface = UserInterface::build( - self.overlay_surface.view(), + self.overlay_surface + .view(participants, click_animation_renderer), self.viewport.logical_size(), user_interface::Cache::default(), &mut self.renderer, @@ -100,41 +108,4 @@ impl IcedRenderer { }; wgpu_renderer.present(None, frame.texture.format(), view, &self.viewport); } - - pub fn add_draw_participant(&mut self, sid: String, color: &str, auto_clear: bool) { - self.overlay_surface - .add_draw_participant(sid, color, auto_clear); - } - - pub fn remove_draw_participant(&mut self, sid: &str) { - self.overlay_surface.remove_draw_participant(sid); - } - - pub fn set_drawing_mode(&mut self, sid: &str, mode: crate::room_service::DrawingMode) { - self.overlay_surface.set_drawing_mode(sid, mode); - } - - pub fn draw_start(&mut self, sid: &str, point: Position, path_id: u64) { - self.overlay_surface.draw_start(sid, point, path_id); - } - - pub fn draw_add_point(&mut self, sid: &str, point: Position) { - self.overlay_surface.draw_add_point(sid, point); - } - - pub fn draw_end(&mut self, sid: &str, point: Position) { - self.overlay_surface.draw_end(sid, point); - } - - pub fn draw_clear_path(&mut self, sid: &str, path_id: u64) { - self.overlay_surface.draw_clear_path(sid, path_id); - } - - pub fn draw_clear_all_paths(&mut self, sid: &str) { - self.overlay_surface.draw_clear_all_paths(sid); - } - - pub fn update_auto_clear(&mut self) -> Vec { - self.overlay_surface.update_auto_clear() - } } diff --git a/core/src/graphics/participant.rs b/core/src/graphics/participant.rs new file mode 100644 index 00000000..a0481053 --- /dev/null +++ b/core/src/graphics/participant.rs @@ -0,0 +1,385 @@ +//! Participant management for remote control sessions. +//! +//! This module provides the Participant and ParticipantsManager types for managing +//! per-participant state including drawing and cursor rendering. + +use crate::utils::geometry::Position; +use crate::utils::svg_renderer::SvgRenderError; +use iced::widget::canvas::{Frame, Geometry}; +use iced::{Rectangle, Renderer}; +use std::collections::{HashMap, VecDeque}; +use thiserror::Error; + +#[path = "draw.rs"] +mod draw; +use draw::Draw; + +#[path = "cursor.rs"] +pub mod cursor; +use cursor::Cursor; + +// Re-export CursorMode for external use +pub use cursor::CursorMode; + +use crate::room_service::DrawingMode; + +const SHARER_COLOR: &str = "#7CCF00"; +const DEFAULT_COLOR: &str = "#FF0000"; + +/// Errors that can occur during participant management. +#[derive(Error, Debug)] +pub enum ParticipantError { + #[error("Participant already exists: {0}")] + AlreadyExists(String), + #[error("SVG render error: {0}")] + SvgRender(#[from] SvgRenderError), +} + +/// Generates a unique visible name based on the full name and existing names. +/// +/// # Algorithm +/// - For "John Smith" with no conflicts: returns "John" +/// - If "John" exists: tries "John S", then "John Sm", etc. +/// - If full name exists: adds numbers "John Smith2", "John Smith3", etc. +fn generate_unique_visible_name(name: &str, used_names: &[String]) -> String { + let parts: Vec<&str> = name.split_whitespace().collect(); + let first_name = parts.first().unwrap_or(&name); + + // Try progressively longer candidates + let candidates = if parts.len() > 1 { + let last_name = parts[1]; + let mut candidates = vec![first_name.to_string()]; + + // Add candidates with increasing characters from last name + for i in 1..=last_name.chars().count() { + let partial_last_name: String = last_name.chars().take(i).collect(); + candidates.push(format!("{first_name} {partial_last_name}")); + } + candidates + } else { + vec![first_name.to_string()] + }; + + // Find first unused candidate + for candidate in candidates.iter() { + if !used_names.contains(candidate) { + return candidate.clone(); + } + } + + // Fall back to numbering + let base = candidates.last().unwrap().clone(); + for num in 2.. { + let candidate = format!("{base}{num}"); + if !used_names.contains(&candidate) { + return candidate; + } + } + + unreachable!() +} + +/// Represents a participant in a remote control session. +/// +/// Each participant has their own drawing state, cursor, and color. +#[derive(Debug)] +pub struct Participant { + draw: Draw, + cursor: Cursor, + color: &'static str, +} + +impl Participant { + /// Creates a new participant with the given color, name and auto-clear setting. + /// + /// # Arguments + /// * `color` - Hex color string for the participant's drawings and cursor + /// * `name` - Display name for the participant's cursor + /// * `auto_clear` - Whether to automatically clear paths after 3 seconds + pub fn new(color: &'static str, name: &str, auto_clear: bool) -> Result { + Ok(Self { + draw: Draw::new(color, auto_clear), + cursor: Cursor::new(color, name)?, + color, + }) + } + + /// Returns a reference to the participant's Draw instance. + pub fn draw(&self) -> &Draw { + &self.draw + } + + /// Returns a mutable reference to the participant's Draw instance. + pub fn draw_mut(&mut self) -> &mut Draw { + &mut self.draw + } + + /// Returns a reference to the participant's cursor. + pub fn cursor(&self) -> &Cursor { + &self.cursor + } + + /// Returns a mutable reference to the participant's cursor. + pub fn cursor_mut(&mut self) -> &mut Cursor { + &mut self.cursor + } + + /// Returns the participant's color. + pub fn color(&self) -> &str { + self.color + } +} + +/// Manager that owns Participant objects mapped by participant sid. +/// +/// Each participant gets their own Participant instance with their assigned color, +/// drawing state, and cursor. +#[derive(Debug)] +pub struct ParticipantsManager { + participants: HashMap, + /// Available colors for new controllers + available_colors: VecDeque<&'static str>, +} + +impl Default for ParticipantsManager { + fn default() -> Self { + Self { + participants: HashMap::new(), + available_colors: VecDeque::from([ + "#615FFF", "#009689", "#C800DE", "#00A6F4", "#FFB900", "#ED0040", "#E49500", + "#B80088", "#FF5BFF", "#00D091", + ]), + } + } +} + +impl ParticipantsManager { + pub fn new() -> Self { + Self::default() + } + + /// Adds a new participant with automatic color assignment. + /// + /// # Arguments + /// * `sid` - Session ID for the participant + /// * `name` - Full name of the participant (will be made unique) + /// * `auto_clear` - Whether to automatically clear paths after 3 seconds + /// + /// # Returns + /// The assigned color, or None if no colors are available + pub fn add_participant( + &mut self, + sid: String, + name: &str, + auto_clear: bool, + ) -> Result<(), ParticipantError> { + // Check if participant already exists + if self.participants.contains_key(&sid) { + return Err(ParticipantError::AlreadyExists(sid)); + } + + let color = if sid == "local" { + SHARER_COLOR + } else { + self.available_colors.pop_front().unwrap_or_else(|| { + log::warn!( + "ParticipantsManager::add_participant: no colors available for participant {}", + sid + ); + DEFAULT_COLOR + }) + }; + + let used_names: Vec = self + .participants + .values() + .map(|p| p.cursor().visible_name().to_string()) + .collect(); + let visible_name = generate_unique_visible_name(name, &used_names); + + log::info!( + "ParticipantsManager::add_participant: sid={} color={} auto_clear={}", + sid, + color, + auto_clear + ); + + self.participants + .insert(sid, Participant::new(color, &visible_name, auto_clear)?); + Ok(()) + } + + /// Removes a participant and their data. + pub fn remove_participant(&mut self, sid: &str) { + log::info!("ParticipantsManager::remove_participant: sid={}", sid); + let participant = self.participants.remove(sid); + if participant.is_none() { + log::warn!( + "ParticipantsManager::remove_participant: participant {} not found", + sid + ); + return; + }; + let participant = participant.unwrap(); + if sid != "local" { + self.available_colors.push_back(participant.color); + } + } + + /// Sets the drawing mode for a specific participant. + pub fn set_drawing_mode(&mut self, sid: &str, mode: DrawingMode) { + log::debug!( + "ParticipantsManager::set_drawing_mode: sid={} mode={:?}", + sid, + mode + ); + if let Some(participant) = self.participants.get_mut(sid) { + participant.draw_mut().set_mode(mode); + } else { + log::warn!( + "ParticipantsManager::set_drawing_mode: participant {} not found", + sid + ); + } + } + + /// Starts a new drawing path for a participant. + pub fn draw_start(&mut self, sid: &str, point: Position, path_id: u64) { + log::debug!( + "ParticipantsManager::draw_start: sid={} point={:?} path_id={}", + sid, + point, + path_id + ); + if let Some(participant) = self.participants.get_mut(sid) { + participant.draw_mut().start_path(path_id, point); + } else { + log::warn!( + "ParticipantsManager::draw_start: participant {} not found", + sid + ); + } + } + + /// Adds a point to the current drawing path for a participant. + pub fn draw_add_point(&mut self, sid: &str, point: Position) { + log::debug!( + "ParticipantsManager::draw_add_point: sid={} point={:?}", + sid, + point + ); + if let Some(participant) = self.participants.get_mut(sid) { + participant.draw_mut().add_point(point); + } else { + log::warn!( + "ParticipantsManager::draw_add_point: participant {} not found", + sid + ); + } + } + + /// Ends the current drawing path for a participant. + pub fn draw_end(&mut self, sid: &str, point: Position) { + log::debug!( + "ParticipantsManager::draw_end: sid={} point={:?}", + sid, + point + ); + if let Some(participant) = self.participants.get_mut(sid) { + participant.draw_mut().add_point(point); + participant.draw_mut().finish_path(); + } else { + log::warn!( + "ParticipantsManager::draw_end: participant {} not found", + sid + ); + } + } + + /// Clears a specific drawing path for a participant. + pub fn draw_clear_path(&mut self, sid: &str, path_id: u64) { + log::debug!( + "ParticipantsManager::draw_clear_path: sid={} path_id={}", + sid, + path_id + ); + if let Some(participant) = self.participants.get_mut(sid) { + participant.draw_mut().clear_path(path_id); + } else { + log::warn!( + "ParticipantsManager::draw_clear_path: participant {} not found", + sid + ); + } + } + + /// Clears all drawing paths for a participant. + pub fn draw_clear_all_paths(&mut self, sid: &str) { + log::info!("ParticipantsManager::draw_clear_all_paths: sid={}", sid); + if let Some(participant) = self.participants.get_mut(sid) { + participant.draw_mut().clear(); + } else { + log::warn!( + "ParticipantsManager::draw_clear_all_paths: participant {} not found", + sid + ); + } + } + + /// Updates auto-clear for all participants and returns removed path IDs. + /// + /// This should be called periodically to expire old paths for participants + /// with auto_clear enabled. + /// + /// # Returns + /// A vector of removed path IDs + pub fn update_auto_clear(&mut self) -> Vec { + let mut removed_path_ids = Vec::new(); + for participant in self.participants.values_mut() { + removed_path_ids.extend(participant.draw_mut().clear_expired_paths()); + } + removed_path_ids + } + + /// Sets the cursor position for a specific participant. + pub fn set_cursor_position(&mut self, sid: &str, position: Option) { + if let Some(participant) = self.participants.get_mut(sid) { + participant.cursor_mut().set_position(position); + } + } + + /// Sets the cursor mode for a participant. + pub fn set_cursor_mode(&mut self, sid: &str, mode: CursorMode) { + if let Some(participant) = self.participants.get_mut(sid) { + participant.cursor_mut().set_mode(mode); + } + } + + /// Renders all participants' drawings and cursors. + /// + /// # Returns + /// A vector of Geometry objects representing all rendered content + pub fn draw(&self, renderer: &Renderer, bounds: Rectangle) -> Vec { + let mut geometries = Vec::with_capacity(self.participants.len() + 1); + + // Collect cached completed geometries from each participant's Draw + for participant in self.participants.values() { + geometries.push(participant.draw().draw_completed(renderer, bounds)); + } + + // Draw all in-progress paths into a single frame + let mut in_progress_frame = Frame::new(renderer, bounds.size()); + for participant in self.participants.values() { + participant + .draw() + .draw_in_progress_to_frame(&mut in_progress_frame); + } + + for participant in self.participants.values() { + participant.cursor().draw(&mut in_progress_frame); + } + geometries.push(in_progress_frame.into_geometry()); + + geometries + } +} diff --git a/core/src/graphics/point.rs b/core/src/graphics/point.rs deleted file mode 100644 index 47887d0c..00000000 --- a/core/src/graphics/point.rs +++ /dev/null @@ -1,114 +0,0 @@ -/// 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. - /// - /// Takes input coordinates in screen percentage and converts them to the - /// appropriate shift in device coordinates. - /// - /// # Arguments - /// * `x` - width percentage - /// * `y` - height percentage - /// - /// # 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 deleted file mode 100644 index b5e8e395..00000000 --- a/core/src/graphics/shader.wgsl +++ /dev/null @@ -1,80 +0,0 @@ -struct VertexOutput { - @builtin(position) clip_position: vec4, - @location(0) texture_coords: vec2, -}; - -struct VertexInput { - @location(0) position: vec2, - @location(1) texture_coords: vec2, -}; - -struct CoordsUniform { - transform: mat4x4, -}; - - -@group(1) @binding(0) -var coords: CoordsUniform; - -@vertex -fn vs_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(0) @binding(0) -var t_diffuse: texture_2d; -@group(0) @binding(1) -var s_diffuse: sampler; - -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - return textureSample(t_diffuse, s_diffuse, in.texture_coords); -} - -@vertex -fn vs_lines_main( - model: VertexInput, -) -> VertexOutput { - var out: VertexOutput; - 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 = fwidth(dist); - let ring_dist = abs(dist - radius); - let alpha = 1.0 - smoothstep(ring_width * 0.5, ring_width * 0.5 + edge, ring_dist); - - 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 2b8c7bd6..8924c3ed 100644 --- a/core/src/input/mouse.rs +++ b/core/src/input/mouse.rs @@ -1,19 +1,12 @@ use std::{ - collections::VecDeque, - sync::{ - mpsc::{Receiver, RecvTimeoutError, Sender}, - Arc, Mutex, - }, - thread::JoinHandle, + sync::{mpsc::Sender, Arc, Mutex}, time::{Duration, Instant}, }; use crate::{ - graphics::graphics_context::{ - click_animation::ANIMATION_DURATION, cursor::Cursor, GraphicsContext, - }, + graphics::graphics_context::{participant::cursor::CursorMode, RedrawThreadCommands}, overlay_window::OverlayWindow, - utils::{geometry::Position, svg_renderer::render_user_badge_to_png}, + utils::{clock::Clock, geometry::Position}, MouseClickData, ScrollDelta, UserEvent, }; @@ -92,10 +85,7 @@ pub use platform::{CursorSimulator, MouseObserver}; /// 4. Loop is broken, preventing recursive event generation pub const CUSTOM_MOUSE_EVENT: i64 = 1234; -/// Maximum number of simultaneous remote controllers supported by the system. -const MAX_CURSORS: u32 = 10; - -const SHARER_COLOR: &str = "#7CCF00"; +const CURSOR_HIDE_TIMEOUT: Duration = Duration::from_secs(5); const SHARER_POSITION_UPDATE_INTERVAL: Duration = Duration::from_millis(30); @@ -105,42 +95,12 @@ const SHARER_POSITION_UPDATE_INTERVAL: Duration = Duration::from_millis(30); /// CursorController creation, enabling better error handling and debugging. #[derive(Debug, thiserror::Error)] pub enum CursorControllerError { - /// Failed to create the sharer's cursor graphic. - /// - /// This typically indicates a problem loading the native cursor texture - /// or insufficient graphics resources. - #[error("Failed to create sharer cursor")] - SharerCursorCreationFailed, - - /// Failed to create the controller's cursor graphic. - /// - /// This typically indicates a problem loading the controller cursor texture - /// or insufficient graphics resources. - #[error("Failed to create controller cursor")] - ControllerCursorCreationFailed, - - #[error("Controller already exists")] - ControllerAlreadyExists, - - #[error("Failed to render SVG badge")] - SvgRenderError, - - /// Failed to create the controller's pointer cursor graphic. - /// - /// This typically indicates a problem loading the pointer cursor texture - /// or insufficient graphics resources. - #[error("Failed to create controller pointer cursor")] - ControllerPointerCursorCreationFailed, - /// Failed to initialize platform-specific mouse event capture. /// /// This indicates the underlying platform API failed to initialize. /// Common causes include missing permissions or insufficient privileges. #[error("Failed to create mouse observer")] MouseObserverCreationFailed, - - #[error("Max controllers reached")] - MaxControllersReached, } /// Platform-agnostic trait for mouse event simulation. @@ -195,81 +155,29 @@ pub trait CursorSimulatorFunctions { fn simulate_scroll(&mut self, delta: ScrollDelta); } -enum CursorWrapperCommands { - Hide, - Show(Position), - Terminate, -} - -/// This thread is used for updating the virtual cursor's position, -/// when there isn't any events for 5 seconds, we hide the cursor. -fn cursor_wrapper_thread( - cursor: Arc>, - receiver: Receiver, - redraw_thread_sender: Sender, -) { - let timeout = Duration::from_secs(5); - loop { - match receiver.recv_timeout(timeout) { - Ok(command) => match command { - CursorWrapperCommands::Hide => { - let mut cursor = cursor.lock().unwrap(); - cursor.set_position(-100., -100.); - if let Err(e) = redraw_thread_sender.send(RedrawThreadCommands::Redraw) { - log::error!("cursor_wrapper_thread: error sending redraw event: {e:?}"); - } - } - CursorWrapperCommands::Show(position) => { - let mut cursor = cursor.lock().unwrap(); - cursor.set_position(position.x, position.y); - if let Err(e) = redraw_thread_sender.send(RedrawThreadCommands::Redraw) { - log::error!("cursor_wrapper_thread: error sending redraw event: {e:?}"); - } - } - CursorWrapperCommands::Terminate => { - break; - } - }, - Err(e) => match e { - RecvTimeoutError::Timeout => { - let mut cursor = cursor.lock().unwrap(); - cursor.set_position(-100., -100.); - if let Err(e) = redraw_thread_sender.send(RedrawThreadCommands::Redraw) { - log::error!("cursor_wrapper_thread: error sending redraw event: {e:?}"); - } - } - _ => { - log::error!("cursor_wrapper_thread: error receiving command: {e:?}"); - break; - } - }, - } - } -} - -struct CursorWrapper { - cursor: Arc>, +struct CursorState { /// Cursor's position in global coordinates, this is used when simulating events global_position: Position, /// Cursor's position in local coordinates, this is used for rendering local_position: Position, - /// Handle for the thread that updates the cursor's position - hide_handle: Option>, - command_sender: Sender, + /// Timestamp of the last time the cursor was shown, used for auto-hiding + last_show_time: Option, + /// Whether the cursor is currently visible + visible: bool, + redraw_thread_sender: Sender, + /// Clock for time tracking + clock: Arc, } -impl CursorWrapper { - fn new(cursor: Cursor, redraw_thread_sender: Sender) -> Self { - let cursor = Arc::new(Mutex::new(cursor)); - let (tx, rx) = std::sync::mpsc::channel(); +impl CursorState { + fn new(redraw_thread_sender: Sender, clock: Arc) -> Self { Self { - cursor: cursor.clone(), global_position: Position::default(), local_position: Position::default(), - hide_handle: Some(std::thread::spawn(move || { - cursor_wrapper_thread(cursor, rx, redraw_thread_sender) - })), - command_sender: tx, + last_show_time: None, + visible: false, + redraw_thread_sender, + clock, } } @@ -280,147 +188,116 @@ impl CursorWrapper { self.global_position = global_position; self.local_position = local_position; if show { + self.last_show_time = Some(self.clock.now()); + self.visible = true; if let Err(e) = self - .command_sender - .send(CursorWrapperCommands::Show(local_position)) + .redraw_thread_sender + .send(RedrawThreadCommands::Activity) { - log::error!("set_position: error sending show command: {e:?}"); + log::error!("set_position: error sending redraw event: {e:?}"); } } } fn hide(&mut self) { - if let Err(e) = self.command_sender.send(CursorWrapperCommands::Hide) { - log::error!("hide: error sending hide command: {e:?}"); + self.last_show_time = None; + self.visible = false; + if let Err(e) = self + .redraw_thread_sender + .send(RedrawThreadCommands::Activity) + { + log::error!("hide: error sending redraw event: {e:?}"); } } fn show(&mut self) { + self.last_show_time = Some(self.clock.now()); + self.visible = true; if let Err(e) = self - .command_sender - .send(CursorWrapperCommands::Show(self.local_position)) + .redraw_thread_sender + .send(RedrawThreadCommands::Activity) { - log::error!("show: error sending show command: {e:?}"); + log::error!("show: error sending redraw event: {e:?}"); } } - fn draw(&self, render_pass: &mut wgpu::RenderPass, gfx: &GraphicsContext) { - let cursor = self.cursor.lock().unwrap(); - cursor.update_transform_buffer(gfx); - cursor.draw(render_pass, gfx); - } -} - -impl Drop for CursorWrapper { - fn drop(&mut self) { - if let Some(handle) = self.hide_handle.take() { - let res = self.command_sender.send(CursorWrapperCommands::Terminate); - if let Err(e) = res { - log::error!("cursor_wrapper_thread: error sending terminate command: {e:?}"); - } else { - let _ = handle.join(); + fn hide_if_expired(&mut self) { + if let Some(last_show) = self.last_show_time { + if self.clock.now().duration_since(last_show) > CURSOR_HIDE_TIMEOUT { + self.hide(); } } } } struct ControllerCursor { - /// Cursor that is shown when the controller is allowed to take control - control_cursor: CursorWrapper, - /// Cursor that is shown when the controller is not allowed to take control - pointer_cursor: CursorWrapper, + /// Cursor state + cursor_state: CursorState, /* * This is used to record when the controller * clicked down. Then for each mouse move we * send LeftMouseDragged instead of MouseMoved. */ clicked: bool, - enabled: bool, - pointer_enabled: bool, + mode: CursorMode, + pointer_mode: bool, has_control: bool, - visible_name: String, sid: String, - color: &'static str, } impl ControllerCursor { - fn new( - control_cursor: CursorWrapper, - pointer_cursor: CursorWrapper, - sid: String, - visible_name: String, - enabled: bool, - color: &'static str, - ) -> Self { + fn new(cursor_state: CursorState, sid: String, mode: CursorMode) -> Self { Self { - control_cursor, - pointer_cursor, + cursor_state, clicked: false, - enabled, - pointer_enabled: false, + mode, + pointer_mode: mode == CursorMode::Pointer, has_control: false, - visible_name, sid, - color, } } fn set_position(&mut self, global_position: Position, local_position: Position) { log::debug!( - "controller_cursor: set_position: global_position: {:?} local_position: {:?} has_control: {} enabled: {}", + "controller_cursor: set_position: global_position: {:?} local_position: {:?} has_control: {} mode: {:?}", global_position, local_position, self.has_control, - self.enabled_control_cursor() - ); - self.control_cursor.set_position( - global_position, - local_position, - !self.has_control && self.enabled_control_cursor(), - ); - self.pointer_cursor.set_position( - global_position, - local_position, - !self.has_control && !self.enabled_control_cursor(), + self.mode ); + self.cursor_state + .set_position(global_position, local_position, !self.has_control); } fn show(&mut self) { self.has_control = false; - if self.enabled_control_cursor() { - self.control_cursor.show(); - } else { - self.pointer_cursor.show(); - } + self.cursor_state.show(); } fn hide(&mut self) { - if self.enabled_control_cursor() { - self.has_control = true; - self.control_cursor.hide(); - } else { - self.pointer_cursor.hide(); - } + self.has_control = true; + self.cursor_state.hide(); } - fn enabled_control_cursor(&self) -> bool { - self.enabled && !self.pointer_enabled + fn mode(&self) -> CursorMode { + self.mode } - fn enabled(&self) -> bool { - self.enabled + fn set_mode(&mut self, mode: CursorMode) { + self.mode = mode; } - fn set_enabled(&mut self, enabled: bool) { - self.enabled = enabled; - - if enabled && !self.pointer_enabled { - self.control_cursor.show(); - self.pointer_cursor.hide(); + fn set_pointer_mode(&mut self, enabled: bool, remote_control_enabled: bool) { + if !enabled && remote_control_enabled { + self.mode = CursorMode::Normal; } else { - self.control_cursor.hide(); - self.pointer_cursor.show(); + self.mode = CursorMode::Pointer; } + self.pointer_mode = enabled; + } + + fn pointer_mode(&self) -> bool { + self.pointer_mode } fn clicked(&mut self) -> bool { @@ -432,39 +309,23 @@ impl ControllerCursor { } fn global_position(&self) -> Position { - self.control_cursor.global_position + self.cursor_state.global_position } - fn draw(&self, render_pass: &mut wgpu::RenderPass, gfx: &GraphicsContext) { - if self.has_control { - return; - } + fn local_position(&self) -> Position { + self.cursor_state.local_position + } - if self.enabled && !self.pointer_enabled { - self.control_cursor.draw(render_pass, gfx); - } else { - self.pointer_cursor.draw(render_pass, gfx); - } + fn visible(&self) -> bool { + self.cursor_state.visible } fn has_control(&self) -> bool { self.has_control } - 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 pointer_enabled(&self) -> bool { - self.pointer_enabled + fn hide_if_expired(&mut self) { + self.cursor_state.hide_if_expired(); } } @@ -479,7 +340,7 @@ fn is_out_of_bounds(position: Position) -> bool { } pub struct SharerCursor { - cursor: CursorWrapper, + cursor_state: CursorState, has_control: bool, event_loop_proxy: EventLoopProxy, overlay_window: Arc, @@ -492,14 +353,14 @@ pub struct SharerCursor { impl SharerCursor { fn new( - cursor: CursorWrapper, + cursor_state: CursorState, event_loop_proxy: EventLoopProxy, overlay_window: Arc, cursor_simulator: Arc>, controllers_cursors: Arc>>, ) -> Self { Self { - cursor, + cursor_state, has_control: true, event_loop_proxy, overlay_window, @@ -521,7 +382,7 @@ impl SharerCursor { .overlay_window .global_percentage_from_global(global_position.x, global_position.y); - self.cursor + self.cursor_state .set_position(global_position, local_position, !self.has_control); // This needs to be after we have set the position in order to use the correct global position @@ -591,24 +452,26 @@ impl SharerCursor { } fn global_position(&self) -> Position { - self.cursor.global_position + self.cursor_state.global_position } - fn draw(&self, render_pass: &mut wgpu::RenderPass, gfx: &GraphicsContext) { - if !self.has_control { - self.cursor.draw(render_pass, gfx); - } + fn local_position(&self) -> Position { + self.cursor_state.local_position + } + + fn visible(&self) -> bool { + self.cursor_state.visible } fn show(&mut self) { self.has_control = false; - self.cursor.show(); + self.cursor_state.show(); } // show_controller needs to be false when this is called from another object. fn hide(&mut self, show_controller: bool) { self.has_control = true; - self.cursor.hide(); + self.cursor_state.hide(); { let mut cursor_simulator = self.cursor_simulator.lock().unwrap(); @@ -644,71 +507,6 @@ impl SharerCursor { } } -enum RedrawThreadCommands { - Redraw, - ClickAnimation(bool), - Stop, -} - -fn redraw_thread( - event_loop_proxy: EventLoopProxy, - receiver: Receiver, - tx: Sender, -) { - let mut last_redraw_time = Instant::now(); - let mut last_click_animation_time = None; - let redraw_interval = std::time::Duration::from_millis(16); - let animation_duration = (ANIMATION_DURATION + 500) as u128; - loop { - match receiver.recv() { - Ok(command) => match command { - RedrawThreadCommands::Stop => break, - RedrawThreadCommands::Redraw => { - if last_redraw_time.elapsed() > redraw_interval { - if let Err(e) = event_loop_proxy.send_event(UserEvent::RequestRedraw) { - log::error!("redraw_thread: error sending redraw event: {e:?}"); - } - last_redraw_time = Instant::now(); - } - } - RedrawThreadCommands::ClickAnimation(extend) => { - if last_redraw_time.elapsed() > redraw_interval { - if let Err(e) = event_loop_proxy.send_event(UserEvent::RequestRedraw) { - log::error!("redraw_thread: error sending redraw event: {e:?}"); - } - last_redraw_time = Instant::now(); - } - - if extend || last_click_animation_time.is_none() { - last_click_animation_time = Some(Instant::now()); - } - - if last_click_animation_time - .as_ref() - .unwrap() - .elapsed() - .as_millis() - < animation_duration - { - std::thread::sleep(std::time::Duration::from_millis(16)); - if let Err(e) = tx.send(RedrawThreadCommands::ClickAnimation(false)) { - log::error!( - "redraw_thread: error sending click animation event: {e:?}" - ); - } - } else { - last_click_animation_time = None; - } - } - }, - Err(e) => { - log::error!("redraw_thread: error receiving command: {e:?}"); - break; - } - } - } -} - struct RemoteControl { /// Cursor that is shown when the sharer looses control sharer_cursor: Arc>, @@ -739,12 +537,7 @@ struct RemoteControl { /// /// ## Error Handling: /// Constructor returns `CursorControllerError` with specific failure reasons: -/// - `SharerCursorCreationFailed`: Graphics resources unavailable -/// - `ControllerCursorCreationFailed`: Controller cursor texture failed -/// - `ControllerPointerCursorCreationFailed`: Pointer cursor texture failed /// - `MouseObserverCreationFailed`: Platform mouse capture initialization failed -/// - `ControllerAlreadyExists`: Attempted to add controller with existing SID -/// - `MaxControllersReached`: Exceeded maximum number of controllers pub struct CursorController { /// Objects that are used for remote control. When accessibility permission is not granted, /// this is None. @@ -755,62 +548,49 @@ pub struct CursorController { controllers_cursors_enabled: bool, /// Object that is used to translate coordinates between local and global overlay_window: Arc, - /// Thread that is used to control the redraws - redraw_thread: Option>, /// Sender for the redraw thread redraw_thread_sender: Sender, /// Event loop proxy for sending events event_loop_proxy: EventLoopProxy, - /// Available colors for new controllers - available_colors: VecDeque<&'static str>, + /// Clock for time tracking + clock: Arc, } impl CursorController { /// Creates a new cursor controller with platform-specific mouse capture and simulation. /// /// This function initializes all necessary components for cursor management: - /// - Creates visual cursor representation for the local sharer /// - Sets up platform-specific mouse event capture /// - Initializes cursor simulation capabilities - /// - Establishes communication with the graphics and overlay systems + /// - Establishes communication with the overlay systems /// - Prepares infrastructure for managing multiple remote controllers /// /// Note: Remote controllers are added separately using `add_controller()` method. /// /// # Parameters /// - /// * `gfx` - Graphics context for creating cursor textures and render resources /// * `overlay_window` - Shared overlay window for coordinate transformations + /// * `redraw_thread_sender` - Sender for triggering redraws /// * `event_loop_proxy` - Event loop proxy for sending cursor position updates + /// * `accessibility_permission` - Whether accessibility permissions are granted + /// * `clock` - Clock for time tracking /// /// # Returns /// /// * `Ok(CursorController)` - Successfully initialized controller /// * `Err(CursorControllerError)` - Specific failure reason (see error variants) - pub fn new( - gfx: &mut GraphicsContext, + pub(crate) fn new( overlay_window: Arc, + redraw_thread_sender: Sender, event_loop_proxy: EventLoopProxy, accessibility_permission: bool, + clock: Arc, ) -> Result { let controllers_cursors = Arc::new(Mutex::new(vec![])); - - let event_loop_proxy_clone = event_loop_proxy.clone(); - - let (sender, receiver) = std::sync::mpsc::channel(); let remote_control = if accessibility_permission { - let scale_factor = overlay_window.get_display_scale(); - let color = SHARER_COLOR; - let svg_badge = render_user_badge_to_png(color, "Me ", false) - .map_err(|_| CursorControllerError::SvgRenderError)?; - let sharer_cursor = match gfx.create_cursor(&svg_badge, scale_factor) { - Ok(cursor) => cursor, - Err(_) => return Err(CursorControllerError::SharerCursorCreationFailed), - }; - let cursor_simulator = Arc::new(Mutex::new(CursorSimulator::new())); let sharer_cursor = Arc::new(Mutex::new(SharerCursor::new( - CursorWrapper::new(sharer_cursor, sender.clone()), + CursorState::new(redraw_thread_sender.clone(), clock.clone()), event_loop_proxy.clone(), overlay_window.clone(), cursor_simulator.clone(), @@ -833,107 +613,54 @@ impl CursorController { None }; - let available = VecDeque::from([ - "#615FFF", "#009689", "#C800DE", "#00A6F4", "#FFB900", "#ED0040", "#E49500", "#B80088", - "#FF5BFF", "#00D091", - ]); - - let sender_clone = sender.clone(); Ok(Self { remote_control, controllers_cursors, controllers_cursors_enabled: accessibility_permission, overlay_window, - redraw_thread: Some(std::thread::spawn(move || { - redraw_thread(event_loop_proxy_clone, receiver, sender_clone); - })), - redraw_thread_sender: sender, + redraw_thread_sender, event_loop_proxy, - available_colors: available, + clock, }) } /// Adds a new remote controller to the cursor management system. /// - /// This function creates visual cursor representations for a new remote controller - /// and adds it to the active controller list. Each controller gets a unique color - /// badge and can be independently controlled. + /// This function adds a controller to the active controller list for state tracking. /// /// # Parameters /// - /// * `gfx` - Graphics context for creating cursor textures /// * `sid` - Unique session ID for the controller (must not already exist) - /// * `name` - Display name for the controller (used in visual badge) /// /// # Returns /// /// * `Ok(())` - Controller successfully added - /// * `Err(CursorControllerError)` - Addition failed for specific reason: - /// - `ControllerAlreadyExists`: SID already in use - /// - `MaxControllersReached`: Maximum controllers exceeded - /// - `ControllerCursorCreationFailed`: Graphics resource creation failed - /// - `ControllerPointerCursorCreationFailed`: Pointer cursor creation failed - /// - `SvgRenderError`: Badge rendering failed - /// - /// # Name Generation - /// - /// If multiple controllers have the same name, the system automatically generates - /// unique visible names (e.g., "John" → "John", "John S", "John Smith", "John Smith2"). - pub fn add_controller( - &mut self, - gfx: &mut GraphicsContext, - sid: String, - name: String, - ) -> Result<(), CursorControllerError> { + pub fn add_controller(&mut self, sid: String) { let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); log::info!( "add_controller: sid: {} controllers_cursors: {}", sid, controllers_cursors.len() ); + + // Check if controller already exists for controller in controllers_cursors.iter() { if controller.sid == sid { - return Err(CursorControllerError::ControllerAlreadyExists); + log::warn!("add_controller: controller {} already exists", sid); + return; } } - if controllers_cursors.len() + 1 > MAX_CURSORS as usize { - return Err(CursorControllerError::MaxControllersReached); - } - - let color = match self.available_colors.pop_front() { - Some(color) => color, - None => return Err(CursorControllerError::MaxControllersReached), - }; - let used_names: Vec = controllers_cursors - .iter() - .map(|c| c.visible_name.clone()) - .collect(); - let visible_name = generate_unique_visible_name(&name, &used_names); - let scale_factor = self.overlay_window.get_display_scale(); - let svg_badge = render_user_badge_to_png(color, &visible_name, false) - .map_err(|_| CursorControllerError::SvgRenderError)?; - - let controller_cursor = match gfx.create_cursor(&svg_badge, scale_factor) { - Ok(cursor) => cursor, - Err(_) => return Err(CursorControllerError::ControllerCursorCreationFailed), - }; - let svg_badge_pointer = render_user_badge_to_png(color, &visible_name, true) - .map_err(|_| CursorControllerError::SvgRenderError)?; - let controller_pointer_cursor = match gfx.create_cursor(&svg_badge_pointer, scale_factor) { - Ok(cursor) => cursor, - Err(_) => return Err(CursorControllerError::ControllerPointerCursorCreationFailed), + let mode = if self.controllers_cursors_enabled { + CursorMode::Normal + } else { + CursorMode::Pointer }; - controllers_cursors.push(ControllerCursor::new( - CursorWrapper::new(controller_cursor, self.redraw_thread_sender.clone()), - CursorWrapper::new(controller_pointer_cursor, self.redraw_thread_sender.clone()), + CursorState::new(self.redraw_thread_sender.clone(), self.clock.clone()), sid, - visible_name, - self.controllers_cursors_enabled, - color, + mode, )); - Ok(()) } /// Removes a remote controller from the cursor management system. @@ -960,10 +687,11 @@ impl CursorController { .iter() .position(|controller| controller.sid == sid) { - // take ownership so we can recover color - let controller = controllers_cursors.remove(pos); - self.available_colors.push_back(controller.color); - if let Err(e) = self.redraw_thread_sender.send(RedrawThreadCommands::Redraw) { + controllers_cursors.remove(pos); + if let Err(e) = self + .redraw_thread_sender + .send(RedrawThreadCommands::Activity) + { log::error!("remove_controller: error sending redraw event: {e:?}"); } } else { @@ -1034,8 +762,8 @@ impl CursorController { continue; } - if !controller.enabled() || controller.pointer_enabled() { - log::info!("mouse_click_controller: controller is disabled."); + if controller.mode() != CursorMode::Normal { + log::info!("mouse_click_controller: controller mode is not Normal."); break; } @@ -1127,8 +855,8 @@ impl CursorController { continue; } - if !controller.enabled() || controller.pointer_enabled() { - log::info!("scroll_controller: controller is disabled."); + if controller.mode() != CursorMode::Normal { + log::info!("scroll_controller: controller mode is not Normal."); break; } @@ -1185,8 +913,8 @@ impl CursorController { /// Enables or disables input processing for all controllers. /// /// This function controls whether remote controllers can interact with the - /// local system. When disabled, all controller input events are ignored and no - /// control transfer occurs from any controller. + /// local system. When enabled, cursors show in Normal mode. When disabled, + /// cursors show in Pointer mode (interaction blocked). /// /// # Parameters /// @@ -1200,8 +928,17 @@ impl CursorController { let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); self.controllers_cursors_enabled = enabled; + for controller in controllers_cursors.iter_mut() { - controller.set_enabled(enabled); + if enabled { + if controller.pointer_mode() { + controller.set_mode(CursorMode::Pointer); + } else { + controller.set_mode(CursorMode::Normal); + } + } else { + controller.set_mode(CursorMode::Pointer); + } if controller.has_control() { controller.show(); @@ -1219,16 +956,13 @@ impl CursorController { } } - /// Makes a specific controller disabled, this is triggered by an event from the - /// controller, while set_controllers_enabled is used to disable all controllers - /// and is triggered by the sharer. - /// + /// Switch pointer mode made by the controller. /// # Parameters /// - /// * `enabled` - Whether to show full cursor (true) or minimal pointer (false) /// * `sid` - Session ID identifying which controller to modify - pub fn set_controller_pointer_enabled(&mut self, enabled: bool, sid: &str) { - log::info!("set_controller_pointer_enabled: {enabled} {sid}"); + /// * `enabled` - Whether to enable (true) or disable (false) pointer mode for the specified controller + pub fn set_controller_pointer(&mut self, enabled: bool, sid: &str) { + log::info!("set_controller_pointer: {sid} {enabled}"); let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); for controller in controllers_cursors.iter_mut() { @@ -1237,7 +971,7 @@ impl CursorController { } if controller.has_control() { - log::info!("set_controller_pointer_enabled: controller {sid} has control, give control back to sharer."); + log::info!("set_controller_pointer: controller {sid} has control, give control back to sharer."); controller.show(); let mut sharer_cursor = self .remote_control @@ -1251,49 +985,52 @@ impl CursorController { sharer_cursor.hide(false); } - controller.set_pointer_enabled(enabled); + controller.set_pointer_mode(enabled, self.controllers_cursors_enabled); break; } } - /// Renders all appropriate cursors to the overlay during the graphics draw cycle. + /// Updates cursor positions in the ParticipantsManager for rendering. /// - /// This function is called during each frame rendering to draw the current cursor - /// states to the overlay window. It automatically selects which cursors to display - /// based on the current control state and individual controller configurations. + /// This function translates cursor state to pixel positions and updates the + /// ParticipantsManager for iced-based rendering. /// /// # Parameters /// - /// * `render_pass` - Active wgpu render pass for drawing operations - /// * `gfx` - Graphics context containing shaders, buffers, and render state + /// * `participants_manager` - Mutable reference to the ParticipantsManager /// - pub fn draw(&self, render_pass: &mut wgpu::RenderPass, gfx: &GraphicsContext) { - log::trace!("draw cursors"); - if self.remote_control.is_some() { - let sharer_cursor = self - .remote_control - .as_ref() - .unwrap() - .sharer_cursor - .lock() - .unwrap(); - sharer_cursor.draw(render_pass, gfx); - } - - let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); - for controller in controllers_cursors.iter_mut() { - controller.draw(render_pass, gfx); + pub fn update_cursors( + &self, + participants_manager: &mut crate::graphics::graphics_context::participant::ParticipantsManager, + ) { + // Update sharer cursor + if let Some(remote_control) = &self.remote_control { + let sharer_cursor = remote_control.sharer_cursor.lock().unwrap(); + if sharer_cursor.visible() { + let local_pos = sharer_cursor.local_position(); + let pixel_pos = self + .overlay_window + .get_pixel_position(local_pos.x, local_pos.y); + participants_manager.set_cursor_position("local", Some(pixel_pos)); + } else { + participants_manager.set_cursor_position("local", None); + } } - } - pub fn get_participant_color(&self, sid: &str) -> Option<&'static str> { + // Update controller cursors let controllers_cursors = self.controllers_cursors.lock().unwrap(); for controller in controllers_cursors.iter() { - if controller.sid == sid { - return Some(controller.color); + if controller.visible() { + let local_pos = controller.local_position(); + let pixel_pos = self + .overlay_window + .get_pixel_position(local_pos.x, local_pos.y); + participants_manager.set_cursor_position(&controller.sid, Some(pixel_pos)); + participants_manager.set_cursor_mode(&controller.sid, controller.mode()); + } else { + participants_manager.set_cursor_position(&controller.sid, None); } } - None } pub fn get_overlay_window(&self) -> Arc { @@ -1310,76 +1047,25 @@ impl CursorController { /// /// * `position` - The position where the click animation should be displayed /// * `sid` - Session ID of the controller triggering the animation - pub fn trigger_click_animation(&self, position: Position, sid: &str) { - log::debug!("trigger_click_animation: position: {position:?} sid: {sid}"); - if let Err(e) = self - .event_loop_proxy - .send_event(UserEvent::EnableClickAnimation(position)) - { - error!("trigger_click_animation: error sending enable click animation: {e:?}"); - } - if let Err(e) = self - .redraw_thread_sender - .send(RedrawThreadCommands::ClickAnimation(true)) - { - log::error!("trigger_click_animation: error sending click animation event: {e:?}"); - } - } - - pub fn trigger_render(&self) { - if let Err(e) = self.redraw_thread_sender.send(RedrawThreadCommands::Redraw) { - log::error!("trigger_render: error sending redraw event: {e:?}"); - } - } - pub fn is_controllers_enabled(&self) -> bool { self.controllers_cursors_enabled } -} - -impl Drop for CursorController { - fn drop(&mut self) { - if let Some(handle) = self.redraw_thread.take() { - let _ = self.redraw_thread_sender.send(RedrawThreadCommands::Stop); - let _ = handle.join(); - } - } -} -fn generate_unique_visible_name(name: &str, used_names: &[String]) -> String { - let parts: Vec<&str> = name.split_whitespace().collect(); - let first_name = parts.first().unwrap_or(&name); - - // Try progressively longer candidates - let candidates = if parts.len() > 1 { - let last_name = parts[1]; - let mut candidates = vec![first_name.to_string()]; - - // Add candidates with increasing characters from last name - for i in 1..=last_name.chars().count() { - let partial_last_name: String = last_name.chars().take(i).collect(); - candidates.push(format!("{first_name} {partial_last_name}")); - } - candidates - } else { - vec![first_name.to_string()] - }; - - // Find first unused candidate - for candidate in candidates.iter() { - if !used_names.contains(candidate) { - return candidate.clone(); + /// Hides cursors that have been inactive for longer than `CURSOR_HIDE_TIMEOUT`. + /// + /// This should be called during each redraw cycle, similar to how + /// `update_auto_clear` works for drawing paths. Each cursor tracks + /// when it was last shown, and cursors exceeding the timeout are + /// automatically hidden. + pub fn hide_inactive_cursors(&mut self) { + let mut controllers_cursors = self.controllers_cursors.lock().unwrap(); + for controller in controllers_cursors.iter_mut() { + controller.hide_if_expired(); } - } - // Fall back to numbering - let base = candidates.last().unwrap().clone(); - for num in 2.. { - let candidate = format!("{base}{num}"); - if !used_names.contains(&candidate) { - return candidate; + if let Some(remote_control) = &self.remote_control { + let mut sharer_cursor = remote_control.sharer_cursor.lock().unwrap(); + sharer_cursor.cursor_state.hide_if_expired(); } } - - unreachable!() } diff --git a/core/src/lib.rs b/core/src/lib.rs index 8c57f537..2326e7db 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -18,6 +18,7 @@ pub mod graphics { } pub mod utils { + pub mod clock; pub mod geometry; pub mod svg_renderer; } @@ -111,6 +112,21 @@ struct RemoteControl<'a> { pencil_cursor: winit::window::CustomCursor, } +impl<'a> RemoteControl<'a> { + /// Renders a complete frame by updating cursors, hiding inactive ones, clearing expired paths, and drawing. + /// + /// # Returns + /// Vector of cleared path IDs from auto-clear + pub fn render_frame(&mut self) -> Vec { + self.cursor_controller + .update_cursors(self.gfx.participants_manager_mut()); + self.cursor_controller.hide_inactive_cursors(); + let cleared_path_ids = self.gfx.participants_manager_mut().update_auto_clear(); + self.gfx.draw(); + cleared_path_ids + } +} + /// The main application struct that manages the entire remote desktop control session. /// /// This struct coordinates all aspects of the remote desktop system, including screen capture, @@ -183,7 +199,6 @@ struct LocalDrawing { left_mouse_pressed: bool, current_path_id: u64, last_cursor_position: Option, - last_redraw_time: std::time::Instant, previous_controllers_enabled: bool, cursor_set_times: u32, } @@ -195,14 +210,13 @@ impl LocalDrawing { self.left_mouse_pressed = false; self.current_path_id = 0; self.last_cursor_position = None; - self.last_redraw_time = std::time::Instant::now(); self.previous_controllers_enabled = false; } } impl fmt::Display for LocalDrawing { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "LocalDrawing: enabled: {} permanent: {} left_mouse_pressed: {} current_path_id: {} last_cursor_position: {:?} last_redraw_time: {:?} previous_controllers_enabled: {}", self.enabled, self.permanent, self.left_mouse_pressed, self.current_path_id, self.last_cursor_position, self.last_redraw_time, self.previous_controllers_enabled) + write!(f, "LocalDrawing: enabled: {} permanent: {} left_mouse_pressed: {} current_path_id: {} last_cursor_position: {:?} previous_controllers_enabled: {}", self.enabled, self.permanent, self.left_mouse_pressed, self.current_path_id, self.last_cursor_position, self.previous_controllers_enabled) } } @@ -251,7 +265,6 @@ impl<'a> Application<'a> { left_mouse_pressed: false, current_path_id: 0, last_cursor_position: None, - last_redraw_time: std::time::Instant::now(), previous_controllers_enabled: false, cursor_set_times: 0, }, @@ -437,6 +450,7 @@ impl<'a> Application<'a> { window, self.textures_path.clone(), selected_monitor.scale_factor(), + self.event_loop_proxy.clone(), ) { Ok(context) => context, Err(error) => { @@ -446,7 +460,14 @@ impl<'a> Application<'a> { }; // Add local participant to draw manager with auto-clear enabled - graphics_context.add_draw_participant("local".to_string(), "#FFDF20", true); + graphics_context + .add_participant("local".to_string(), "Me ", true) + .map_err(|e| { + log::error!( + "create_overlay_window: Failed to create local participant cursor: {e}" + ); + ServerError::GfxCreationError + })?; // Load pencil cursor image once during window creation let pencil_path = format!("{}/pencil.png", self.textures_path); @@ -522,11 +543,14 @@ impl<'a> Application<'a> { log::info!("create_overlay_window: overlay_window created {overlay_window}"); + let redraw_sender = graphics_context.redraw_sender(); + let clock = graphics_context.clock(); let cursor_controller = CursorController::new( - &mut graphics_context, overlay_window.clone(), + redraw_sender, self.event_loop_proxy.clone(), accessibility_permission, + clock, ); if let Err(error) = cursor_controller { log::error!("create_overlay_window: Error creating cursor controller {error:?}"); @@ -707,7 +731,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_pointer_enabled(visible, sid.as_str()); + cursor_controller.set_controller_pointer(visible, sid.as_str()); } UserEvent::Keystroke(keystroke_data) => { log::debug!("user_event: keystroke: {keystroke_data:?}"); @@ -818,20 +842,19 @@ impl<'a> ApplicationHandler for Application<'a> { } let remote_control = &mut self.remote_control.as_mut().unwrap(); let sid = participant.sid.clone(); - if let Err(e) = remote_control.cursor_controller.add_controller( - &mut remote_control.gfx, - participant.sid, - participant.name, - ) { - log::error!( - "user_event: Participant connected: Error adding controller: {e:?}" - ); + let name = participant.name.clone(); + + // Add participant to draw manager first (assigns color) + if let Err(e) = remote_control + .gfx + .add_participant(sid.clone(), &name, false) + { + log::error!("Failed to create cursor for participant {sid}: {e}"); return; } - // Add participant to draw manager with their color - if let Some(color) = remote_control.cursor_controller.get_participant_color(&sid) { - remote_control.gfx.add_draw_participant(sid, color, false); - } + + // Then add to cursor controller for state tracking + remote_control.cursor_controller.add_controller(sid); } UserEvent::ParticipantDisconnected(participant) => { log::debug!("user_event: Participant disconnected: {participant:?}"); @@ -846,7 +869,7 @@ impl<'a> ApplicationHandler for Application<'a> { // Remove participant from draw manager remote_control .gfx - .remove_draw_participant(participant.sid.as_str()); + .remove_participant(participant.sid.as_str()); } UserEvent::LivekitServerUrl(url) => { log::debug!("user_event: Livekit server url: {url}"); @@ -883,15 +906,6 @@ 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::AddToClipboard(add_to_clipboard_data) => { log::info!("user_event: Add to clipboard: {add_to_clipboard_data:?}"); if self.remote_control.is_none() { @@ -939,12 +953,12 @@ impl<'a> ApplicationHandler for Application<'a> { DrawingMode::Disabled => { remote_control .cursor_controller - .set_controller_pointer_enabled(false, sid.as_str()); + .set_controller_pointer(false, sid.as_str()); } _ => { remote_control .cursor_controller - .set_controller_pointer_enabled(true, sid.as_str()); + .set_controller_pointer(true, sid.as_str()); } } remote_control @@ -1011,7 +1025,7 @@ impl<'a> ApplicationHandler for Application<'a> { } let remote_control = &mut self.remote_control.as_mut().unwrap(); remote_control.gfx.draw_clear_path(sid.as_str(), path_id); - remote_control.cursor_controller.trigger_render(); + remote_control.gfx.trigger_render(); } UserEvent::DrawClearAllPaths(sid) => { log::debug!("user_event: DrawClearAllPaths: {}", sid); @@ -1021,7 +1035,7 @@ impl<'a> ApplicationHandler for Application<'a> { } let remote_control = &mut self.remote_control.as_mut().unwrap(); remote_control.gfx.draw_clear_all_paths(sid.as_str()); - remote_control.cursor_controller.trigger_render(); + remote_control.gfx.trigger_render(); } UserEvent::ClickAnimationFromParticipant(point, sid) => { log::debug!( @@ -1035,14 +1049,12 @@ impl<'a> ApplicationHandler for Application<'a> { ); return; } - let remote_control = &self.remote_control.as_ref().unwrap(); - let position = Position { - x: point.x, - y: point.y, - }; - remote_control + let remote_control = &mut self.remote_control.as_mut().unwrap(); + let position = remote_control .cursor_controller - .trigger_click_animation(position, sid.as_str()); + .get_overlay_window() + .get_pixel_position(point.x, point.y); + remote_control.gfx.trigger_click_animation(position); } UserEvent::LocalDrawingEnabled(drawing_enabled) => { log::debug!("user_event: LocalDrawingEnabled: {:?}", drawing_enabled); @@ -1097,8 +1109,6 @@ impl<'a> ApplicationHandler for Application<'a> { // Clear all local drawing paths remote_control.gfx.draw_clear_all_paths("local"); - let cursor_controller = &mut remote_control.cursor_controller; - remote_control.gfx.draw(cursor_controller); // Send LiveKit event to clear all paths if let Some(room_service) = &self.room_service { @@ -1131,6 +1141,8 @@ impl<'a> ApplicationHandler for Application<'a> { .set_drawing_mode("local", room_service::DrawingMode::Disabled); log::info!("Local drawing mode disabled"); + + remote_control.gfx.trigger_render(); } } } @@ -1161,44 +1173,31 @@ impl<'a> ApplicationHandler for Application<'a> { event_loop.exit(); } WindowEvent::RedrawRequested => { - // render the cursor - // The vertices should be in counter clockwise order because of the front face culling if self.remote_control.is_none() { log::warn!("window_event: remote control is none redraw requested"); return; } let remote_control = &mut self.remote_control.as_mut().unwrap(); - // Update auto-clear and send events for cleared paths - let cleared_path_ids = remote_control.gfx.update_auto_clear(); + if self.local_drawing.enabled && self.local_drawing.cursor_set_times < 500 { + let window = remote_control.gfx.window(); + window.focus_window(); + window.set_cursor_visible(false); + window.set_cursor_visible(true); + window.set_cursor(remote_control.pencil_cursor.clone()); + self.local_drawing.cursor_set_times += 1; + } + + // Render frame with cursor updates, auto-clear, and drawing + let cleared_path_ids = remote_control.render_frame(); + + // Publish cleared paths to room service if !cleared_path_ids.is_empty() && self.room_service.is_some() { self.room_service .as_ref() .unwrap() .publish_draw_clear_paths(cleared_path_ids); } - - if !self.local_drawing.enabled { - let cursor_controller = &mut remote_control.cursor_controller; - remote_control.gfx.draw(cursor_controller); - } else { - if self.local_drawing.last_redraw_time.elapsed() - > std::time::Duration::from_millis(20) - { - if self.local_drawing.cursor_set_times < 500 { - let window = remote_control.gfx.window(); - window.focus_window(); - window.set_cursor_visible(false); - window.set_cursor_visible(true); - window.set_cursor(remote_control.pencil_cursor.clone()); - self.local_drawing.cursor_set_times += 1; - } - let cursor_controller = &mut remote_control.cursor_controller; - remote_control.gfx.draw(cursor_controller); - self.local_drawing.last_redraw_time = std::time::Instant::now(); - } - remote_control.gfx.window().request_redraw(); - } } WindowEvent::MouseInput { state, button, .. } => { if self.local_drawing.enabled { @@ -1214,7 +1213,7 @@ impl<'a> ApplicationHandler for Application<'a> { position, self.local_drawing.current_path_id, ); - remote_control.cursor_controller.trigger_render(); + remote_control.gfx.trigger_render(); // Send LiveKit event if let Some(room_service) = &self.room_service { @@ -1248,7 +1247,7 @@ impl<'a> ApplicationHandler for Application<'a> { if let Some(position) = self.local_drawing.last_cursor_position { if let Some(remote_control) = &mut self.remote_control { remote_control.gfx.draw_end("local", position); - remote_control.cursor_controller.trigger_render(); + remote_control.gfx.trigger_render(); // Send LiveKit event if let Some(room_service) = &self.room_service { @@ -1274,7 +1273,7 @@ impl<'a> ApplicationHandler for Application<'a> { if let Some(remote_control) = &mut self.remote_control { // Clear all local drawing paths remote_control.gfx.draw_clear_all_paths("local"); - remote_control.cursor_controller.trigger_render(); + remote_control.gfx.trigger_render(); // Send LiveKit event to clear all paths if let Some(room_service) = &self.room_service { @@ -1306,7 +1305,7 @@ impl<'a> ApplicationHandler for Application<'a> { if self.local_drawing.left_mouse_pressed { if let Some(remote_control) = &mut self.remote_control { remote_control.gfx.draw_add_point("local", pos); - remote_control.cursor_controller.trigger_render(); + remote_control.gfx.trigger_render(); // Send LiveKit event if let Some(room_service) = &self.room_service { @@ -1397,7 +1396,6 @@ pub enum UserEvent { ScreenShare(ScreenShareMessage), StopScreenShare, RequestRedraw, - EnableClickAnimation(Position), SharerPosition(f64, f64), ResetState, Tick(u128), diff --git a/core/src/utils/clock.rs b/core/src/utils/clock.rs new file mode 100644 index 00000000..aeeb292e --- /dev/null +++ b/core/src/utils/clock.rs @@ -0,0 +1,50 @@ +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +pub trait Clock: Send + Sync + std::fmt::Debug + 'static { + fn now(&self) -> Instant; +} + +#[derive(Debug, Clone, Copy)] +pub struct RealClock; + +impl Clock for RealClock { + #[inline(always)] + fn now(&self) -> Instant { + Instant::now() + } +} + +pub fn default_clock() -> Arc { + Arc::new(RealClock) +} + +#[derive(Debug)] +pub struct TestClock { + current: Mutex, +} + +impl Default for TestClock { + fn default() -> Self { + Self::new() + } +} + +impl TestClock { + pub fn new() -> Self { + Self { + current: Mutex::new(Instant::now()), + } + } + + pub fn advance(&self, duration: Duration) { + let mut current = self.current.lock().unwrap(); + *current += duration; + } +} + +impl Clock for TestClock { + fn now(&self) -> Instant { + *self.current.lock().unwrap() + } +} diff --git a/core/src/window_manager.rs b/core/src/window_manager.rs index a494b653..c8625d98 100644 --- a/core/src/window_manager.rs +++ b/core/src/window_manager.rs @@ -361,10 +361,10 @@ fn set_fullscreen( if start.elapsed() > timeout { log::error!( - "set_fullscreen: timeout waiting for fullscreen. Current: {:?}, Expected: {:?}", - current_size, - expected_size - ); + "set_fullscreen: timeout waiting for fullscreen. Current: {:?}, Expected: {:?}", + current_size, + expected_size + ); return Err(FullscreenError::FailedToMatchFullscreenSize); } diff --git a/core/tests/src/main.rs b/core/tests/src/main.rs index 10515bab..862973bd 100644 --- a/core/tests/src/main.rs +++ b/core/tests/src/main.rs @@ -241,13 +241,13 @@ async fn main() -> io::Result<()> { Commands::Drawing { test_type } => { match test_type { DrawingTest::PermanentOn => { - println!("Running drawing test with permanent mode ON..."); - remote_drawing::test_draw_and_clear_paths_individually().await?; - } - DrawingTest::PermanentOff => { println!("Running drawing test with permanent mode OFF..."); remote_drawing::test_draw_and_clear_all_paths().await?; } + DrawingTest::PermanentOff => { + println!("Running drawing test with permanent mode ON..."); + remote_drawing::test_draw_and_clear_paths_individually().await?; + } DrawingTest::ClickAnimation => { println!("Running click animation mode test..."); remote_drawing::test_click_animation_mode().await?; diff --git a/core/tests/src/remote_drawing.rs b/core/tests/src/remote_drawing.rs index 1bfd5e11..21c15a18 100644 --- a/core/tests/src/remote_drawing.rs +++ b/core/tests/src/remote_drawing.rs @@ -144,246 +144,6 @@ async fn draw_stroke( Ok(()) } -/// Letter drawing definitions - each letter is a set of strokes -/// Coordinates are normalized (0-1) and will be scaled and positioned - -struct LetterStroke { - from: (f64, f64), - to: (f64, f64), -} - -fn get_letter_strokes(letter: char) -> Vec { - match letter { - 'H' => vec![ - // Left vertical - LetterStroke { - from: (0.0, 0.0), - to: (0.0, 1.0), - }, - // Right vertical - LetterStroke { - from: (0.6, 0.0), - to: (0.6, 1.0), - }, - // Middle horizontal - LetterStroke { - from: (0.0, 0.5), - to: (0.6, 0.5), - }, - ], - 'e' => vec![ - // Middle horizontal - LetterStroke { - from: (0.0, 0.5), - to: (0.5, 0.5), - }, - // Top curve (simplified as line) - LetterStroke { - from: (0.5, 0.5), - to: (0.5, 0.3), - }, - LetterStroke { - from: (0.5, 0.3), - to: (0.25, 0.2), - }, - LetterStroke { - from: (0.25, 0.2), - to: (0.0, 0.3), - }, - LetterStroke { - from: (0.0, 0.3), - to: (0.0, 0.5), - }, - // Bottom curve - LetterStroke { - from: (0.0, 0.5), - to: (0.0, 0.8), - }, - LetterStroke { - from: (0.0, 0.8), - to: (0.25, 1.0), - }, - LetterStroke { - from: (0.25, 1.0), - to: (0.5, 0.9), - }, - ], - 'l' => vec![ - // Vertical line - LetterStroke { - from: (0.2, 0.0), - to: (0.2, 1.0), - }, - ], - 'o' => vec![ - // Circle (simplified as strokes) - LetterStroke { - from: (0.25, 0.2), - to: (0.0, 0.4), - }, - LetterStroke { - from: (0.0, 0.4), - to: (0.0, 0.7), - }, - LetterStroke { - from: (0.0, 0.7), - to: (0.25, 1.0), - }, - LetterStroke { - from: (0.25, 1.0), - to: (0.5, 0.7), - }, - LetterStroke { - from: (0.5, 0.7), - to: (0.5, 0.4), - }, - LetterStroke { - from: (0.5, 0.4), - to: (0.25, 0.2), - }, - ], - ' ' => vec![], // Space - no strokes - 'W' => vec![ - // Left diagonal down - LetterStroke { - from: (0.0, 0.0), - to: (0.2, 1.0), - }, - // Left-center diagonal up - LetterStroke { - from: (0.2, 1.0), - to: (0.4, 0.5), - }, - // Right-center diagonal down - LetterStroke { - from: (0.4, 0.5), - to: (0.6, 1.0), - }, - // Right diagonal up - LetterStroke { - from: (0.6, 1.0), - to: (0.8, 0.0), - }, - ], - 'r' => vec![ - // Vertical stem - LetterStroke { - from: (0.0, 0.3), - to: (0.0, 1.0), - }, - // Top curve - LetterStroke { - from: (0.0, 0.4), - to: (0.2, 0.25), - }, - LetterStroke { - from: (0.2, 0.25), - to: (0.4, 0.3), - }, - ], - 'd' => vec![ - // Bowl - LetterStroke { - from: (0.4, 0.3), - to: (0.2, 0.2), - }, - LetterStroke { - from: (0.2, 0.2), - to: (0.0, 0.4), - }, - LetterStroke { - from: (0.0, 0.4), - to: (0.0, 0.7), - }, - LetterStroke { - from: (0.0, 0.7), - to: (0.2, 1.0), - }, - LetterStroke { - from: (0.2, 1.0), - to: (0.4, 0.8), - }, - // Vertical stem (full height) - LetterStroke { - from: (0.4, 0.0), - to: (0.4, 1.0), - }, - ], - '!' => vec![ - // Vertical line - LetterStroke { - from: (0.2, 0.0), - to: (0.2, 0.7), - }, - // Dot - LetterStroke { - from: (0.2, 0.9), - to: (0.2, 1.0), - }, - ], - _ => vec![], - } -} - -/// Draws a single letter at a given position with a given size -async fn draw_letter( - room: &Room, - letter: char, - base_x: f64, - base_y: f64, - width: f64, - height: f64, - path_id_start: u64, -) -> io::Result<()> { - let strokes = get_letter_strokes(letter); - - for (i, stroke) in strokes.iter().enumerate() { - let from_x = base_x + stroke.from.0 * width; - let from_y = base_y + stroke.from.1 * height; - let to_x = base_x + stroke.to.0 * width; - let to_y = base_y + stroke.to.1 * height; - - draw_stroke(room, from_x, from_y, to_x, to_y, path_id_start + i as u64).await?; - } - - Ok(()) -} - -/// Draws "Hello World!" text -async fn draw_hello_world(room: &Room, start_x: f64, start_y: f64) -> io::Result<()> { - let text = "Hello World!"; - let letter_width = 0.05; - let letter_height = 0.08; - let letter_spacing = 0.06; - let space_width = 0.03; - - let mut current_x = start_x; - let mut path_id = 0; - - for letter in text.chars() { - if letter == ' ' { - current_x += space_width; - } else { - println!("Drawing letter: '{}'", letter); - draw_letter( - room, - letter, - current_x, - start_y, - letter_width, - letter_height, - path_id, - ) - .await?; - // Each letter can have multiple strokes, allocate 10 path_ids per letter to be safe - path_id += 10; - current_x += letter_spacing; - } - } - - Ok(()) -} - /// Test click animation mode - Basic functionality /// Triggers click animations at various points on the screen pub async fn test_click_animation_mode() -> io::Result<()> {