//! Custom cursor system //! //! Provides SVG-based custom cursors beyond egui's built-in system cursors. //! When a custom cursor is active, the system cursor is hidden and the SVG //! cursor image is drawn at the pointer position. use eframe::egui; use egui::TextureHandle; use lightningbeam_core::tool::Tool; use std::collections::HashMap; /// Custom cursor identifiers #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CustomCursor { // Stage tool cursors Select, Draw, Transform, Rectangle, Ellipse, PaintBucket, Eyedropper, Line, Polygon, BezierEdit, Text, // Timeline cursors LoopExtend, } impl CustomCursor { /// Convert a Tool enum to the corresponding custom cursor pub fn from_tool(tool: Tool) -> Self { match tool { Tool::Select => CustomCursor::Select, Tool::Draw => CustomCursor::Draw, Tool::Transform => CustomCursor::Transform, Tool::Rectangle => CustomCursor::Rectangle, Tool::Ellipse => CustomCursor::Ellipse, Tool::PaintBucket => CustomCursor::PaintBucket, Tool::Eyedropper => CustomCursor::Eyedropper, Tool::Line => CustomCursor::Line, Tool::Polygon => CustomCursor::Polygon, Tool::BezierEdit => CustomCursor::BezierEdit, Tool::Text => CustomCursor::Text, Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now Tool::Split => CustomCursor::Select, // Reuse select cursor for now Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge } } /// Hotspot offset — the "click point" relative to the image top-left pub fn hotspot(&self) -> egui::Vec2 { match self { // Select cursor: pointer tip at top-left CustomCursor::Select => egui::vec2(3.0, 1.0), // Drawing tools: tip at bottom-left CustomCursor::Draw => egui::vec2(1.0, 23.0), // Transform: center CustomCursor::Transform => egui::vec2(12.0, 12.0), // Shape tools: crosshair at center CustomCursor::Rectangle | CustomCursor::Ellipse | CustomCursor::Line | CustomCursor::Polygon => egui::vec2(12.0, 12.0), // Paint bucket: tip at bottom-left CustomCursor::PaintBucket => egui::vec2(2.0, 21.0), // Eyedropper: tip at bottom CustomCursor::Eyedropper => egui::vec2(4.0, 22.0), // Bezier edit: tip at top-left CustomCursor::BezierEdit => egui::vec2(3.0, 1.0), // Text: I-beam center CustomCursor::Text => egui::vec2(12.0, 12.0), // Loop extend: center of circular arrow CustomCursor::LoopExtend => egui::vec2(12.0, 12.0), } } /// Get the embedded SVG data for this cursor fn svg_data(&self) -> &'static [u8] { match self { CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"), CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"), CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"), CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"), CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"), CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"), CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"), CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"), CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"), CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"), CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"), CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"), } } } /// Cache of rasterized cursor textures (black fill + white outline version) pub struct CursorCache { /// Black cursor for the main image textures: HashMap, /// White cursor for the outline outline_textures: HashMap, } impl CursorCache { pub fn new() -> Self { Self { textures: HashMap::new(), outline_textures: HashMap::new(), } } /// Get or lazily load the black (fill) cursor texture pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { self.textures.entry(cursor).or_insert_with(|| { let svg_data = cursor.svg_data(); let svg_string = String::from_utf8_lossy(svg_data); let svg_with_color = svg_string.replace("currentColor", "#000000"); rasterize_cursor_svg(svg_with_color.as_bytes(), &format!("cursor_{:?}", cursor), CURSOR_SIZE, ctx) .expect("Failed to rasterize cursor SVG") }) } /// Get or lazily load the white (outline) cursor texture pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { self.outline_textures.entry(cursor).or_insert_with(|| { let svg_data = cursor.svg_data(); let svg_string = String::from_utf8_lossy(svg_data); // Replace all colors with white for the outline let svg_white = svg_string .replace("currentColor", "#ffffff") .replace("#000000", "#ffffff") .replace("#000", "#ffffff"); rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx) .expect("Failed to rasterize cursor SVG outline") }) } } const CURSOR_SIZE: u32 = 24; const OUTLINE_OFFSET: f32 = 1.0; /// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg) fn rasterize_cursor_svg( svg_data: &[u8], name: &str, render_size: u32, ctx: &egui::Context, ) -> Option { let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; let pixmap_size = tree.size().to_int_size(); let scale_x = render_size as f32 / pixmap_size.width() as f32; let scale_y = render_size as f32 / pixmap_size.height() as f32; let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?; resvg::render( &tree, resvg::tiny_skia::Transform::from_scale(scale_x, scale_y), &mut pixmap.as_mut(), ); let rgba_data = pixmap.data().to_vec(); let color_image = egui::ColorImage::from_rgba_unmultiplied( [render_size as usize, render_size as usize], &rgba_data, ); Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR)) } // --- Per-frame cursor slot using egui context data --- /// Key for storing the active custom cursor in egui's per-frame data #[derive(Clone, Copy)] struct ActiveCustomCursor(CustomCursor); /// Set the custom cursor for this frame. Call from any pane during rendering. /// This hides the system cursor and draws the SVG cursor at pointer position. pub fn set(ctx: &egui::Context, cursor: CustomCursor) { ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor))); } /// Render the custom cursor overlay. Call at the end of the main update loop. pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { // Take and remove the cursor so it doesn't persist to the next frame let id = egui::Id::new("active_custom_cursor"); let cursor = ctx.data_mut(|d| { let val = d.get_temp::(id); d.remove::(id); val }); if let Some(ActiveCustomCursor(cursor)) = cursor { // If a system cursor was explicitly set (resize handles, text inputs, etc.), // let it take priority over the custom cursor let system_cursor = ctx.output(|o| o.cursor_icon); if system_cursor != egui::CursorIcon::Default { return; } // Hide the system cursor ctx.set_cursor_icon(egui::CursorIcon::None); if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) { let hotspot = cursor.hotspot(); let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32); let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); let painter = ctx.debug_painter(); // Draw white outline: render white version offset in 8 directions let outline_tex = cache.get_or_load_outline(cursor, ctx); let outline_id = outline_tex.id(); for &(dx, dy) in &[ (-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0), (0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET), (-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET), (-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET), ] { let offset_rect = egui::Rect::from_min_size( pos - hotspot + egui::vec2(dx, dy), size, ); painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE); } // Draw black fill on top let fill_tex = cache.get_or_load(cursor, ctx); let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size); painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE); } } }