Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs

232 lines
9.6 KiB
Rust

//! 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<CustomCursor, TextureHandle>,
/// White cursor for the outline
outline_textures: HashMap<CustomCursor, TextureHandle>,
}
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<TextureHandle> {
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::<ActiveCustomCursor>(id);
d.remove::<ActiveCustomCursor>(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);
}
}
}