diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs index 86134ea..db85f1a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -82,6 +82,14 @@ pub enum ClipboardContent { /// Notes: (start_time, note, velocity, duration) — times relative to selection start notes: Vec<(f64, u8, u8, f64)>, }, + /// Raw pixel data from a raster layer selection. + /// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes — + /// the same in-memory format as `RasterKeyframe::raw_pixels`. + RasterPixels { + pixels: Vec, + width: u32, + height: u32, + }, } impl ClipboardContent { @@ -176,6 +184,9 @@ impl ClipboardContent { // No IDs to regenerate, just clone (ClipboardContent::MidiNotes { notes: notes.clone() }, id_map) } + ClipboardContent::RasterPixels { pixels, width, height } => { + (ClipboardContent::RasterPixels { pixels: pixels.clone(), width: *width, height: *height }, id_map) + } ClipboardContent::Shapes { shapes } => { // Regenerate shape IDs let new_shapes: Vec = shapes @@ -257,6 +268,62 @@ impl ClipboardManager { None } + /// Copy raster pixels to the system clipboard as an image. + /// + /// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes). + /// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors + /// (arboard is a temporary integration point and will be replaced). + pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) { + let Some(system) = self.system.as_mut() else { return }; + // Unpremultiply: sRGB-premul → straight RGBA8 for the system clipboard. + let straight: Vec = pixels.chunks_exact(4).flat_map(|p| { + let a = p[3]; + if a == 0 { + [0u8, 0, 0, 0] + } else { + let inv = 255.0 / a as f32; + [ + (p[0] as f32 * inv).round().min(255.0) as u8, + (p[1] as f32 * inv).round().min(255.0) as u8, + (p[2] as f32 * inv).round().min(255.0) as u8, + a, + ] + } + }).collect(); + let img = arboard::ImageData { + width: width as usize, + height: height as usize, + bytes: std::borrow::Cow::Owned(straight), + }; + let _ = system.set_image(img); + } + + /// Try to read an image from the system clipboard. + /// + /// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if + /// no image is available. Silently ignores errors. + pub fn try_get_raster_image(&mut self) -> Option<(Vec, u32, u32)> { + let img = self.system.as_mut()?.get_image().ok()?; + let width = img.width as u32; + let height = img.height as u32; + // Premultiply: straight RGBA8 → sRGB-premul. + let premul: Vec = img.bytes.chunks_exact(4).flat_map(|p| { + let a = p[3]; + if a == 0 { + [0u8, 0, 0, 0] + } else { + let scale = a as f32 / 255.0; + [ + (p[0] as f32 * scale).round() as u8, + (p[1] as f32 * scale).round() as u8, + (p[2] as f32 * scale).round() as u8, + a, + ] + } + }).collect(); + Some((premul, width, height)) + } + /// Check if there's content available to paste pub fn has_content(&mut self) -> bool { if self.internal.is_some() { diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index b481e20..2783e71 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -8,6 +8,82 @@ use std::collections::HashSet; use uuid::Uuid; use vello::kurbo::{Affine, BezPath}; +/// Shape of a raster pixel selection, in canvas pixel coordinates. +#[derive(Clone, Debug)] +pub enum RasterSelection { + /// Axis-aligned rectangle: (x0, y0, x1, y1), x1 >= x0, y1 >= y0. + Rect(i32, i32, i32, i32), + /// Closed freehand lasso polygon. + Lasso(Vec<(i32, i32)>), +} + +impl RasterSelection { + /// Bounding box as (x0, y0, x1, y1). + pub fn bounding_rect(&self) -> (i32, i32, i32, i32) { + match self { + Self::Rect(x0, y0, x1, y1) => (*x0, *y0, *x1, *y1), + Self::Lasso(pts) => { + let x0 = pts.iter().map(|p| p.0).min().unwrap_or(0); + let y0 = pts.iter().map(|p| p.1).min().unwrap_or(0); + let x1 = pts.iter().map(|p| p.0).max().unwrap_or(0); + let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0); + (x0, y0, x1, y1) + } + } + } + + /// Returns true if the given canvas pixel is inside the selection. + pub fn contains_pixel(&self, px: i32, py: i32) -> bool { + match self { + Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1, + Self::Lasso(pts) => point_in_polygon(px, py, pts), + } + } +} + +/// Even-odd point-in-polygon test for integer coordinates. +fn point_in_polygon(px: i32, py: i32, polygon: &[(i32, i32)]) -> bool { + let n = polygon.len(); + if n < 3 { return false; } + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = (polygon[i].0 as f64, polygon[i].1 as f64); + let (xj, yj) = (polygon[j].0 as f64, polygon[j].1 as f64); + let x = px as f64; + let y = py as f64; + if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) { + inside = !inside; + } + j = i; + } + inside +} + +/// A pasted or cut selection that floats above the canvas until committed. +/// +/// While a floating selection is alive `raw_pixels` on the target keyframe is +/// left in a "pre-composite" state (hole punched for cut, unchanged for copy). +/// The floating pixels are rendered as an overlay. Committing composites them +/// into `raw_pixels` and records a `RasterStrokeAction` for undo. +#[derive(Clone, Debug)] +pub struct RasterFloatingSelection { + /// sRGB-encoded premultiplied RGBA, width × height × 4 bytes. + pub pixels: Vec, + pub width: u32, + pub height: u32, + /// Top-left position in canvas pixel coordinates. + pub x: i32, + pub y: i32, + /// Which raster layer and keyframe this float belongs to. + pub layer_id: Uuid, + pub time: f64, + /// Snapshot of `raw_pixels` before the cut/paste was initiated, used for + /// undo (via `RasterStrokeAction`) when the float is committed, and for + /// Cancel (Escape) to restore the canvas without creating an undo entry. + pub canvas_before: Vec, +} + /// Tracks the most recently selected thing(s) across the entire document. /// /// Lightweight overlay on top of per-domain selection state. Tells consumers @@ -69,6 +145,16 @@ pub struct Selection { /// Currently selected clip instances selected_clip_instances: Vec, + + /// Active raster pixel selection (marquee or lasso outline). + /// Transient UI state — not persisted. + #[serde(skip)] + pub raster_selection: Option, + + /// Floating raster selection waiting to be committed or cancelled. + /// Transient UI state — not persisted. + #[serde(skip)] + pub raster_floating: Option, } impl Selection { @@ -79,6 +165,8 @@ impl Selection { selected_edges: HashSet::new(), selected_faces: HashSet::new(), selected_clip_instances: Vec::new(), + raster_selection: None, + raster_floating: None, } } @@ -302,6 +390,8 @@ impl Selection { self.selected_edges.clear(); self.selected_faces.clear(); self.selected_clip_instances.clear(); + self.raster_selection = None; + self.raster_floating = None; } /// Check if selection is empty diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 931946c..f79f547 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -41,6 +41,8 @@ pub enum Tool { Erase, /// Smudge tool - smudge/blend raster pixels Smudge, + /// Lasso select tool - freehand selection on raster layers + SelectLasso, } /// Region select mode @@ -75,6 +77,17 @@ pub enum ToolState { points: Vec, }, + /// Drawing a freehand lasso selection on a raster layer + DrawingRasterLasso { + points: Vec<(i32, i32)>, + }, + + /// Drawing a rectangular marquee selection on a raster layer + DrawingRasterMarquee { + start: (i32, i32), + current: (i32, i32), + }, + /// Dragging selected objects DraggingSelection { start_pos: Point, @@ -224,6 +237,7 @@ impl Tool { Tool::Split => "Split", Tool::Erase => "Erase", Tool::Smudge => "Smudge", + Tool::SelectLasso => "Lasso Select", } } @@ -245,6 +259,7 @@ impl Tool { Tool::Split => "split.svg", Tool::Erase => "erase.svg", Tool::Smudge => "smudge.svg", + Tool::SelectLasso => "lasso.svg", } } @@ -272,29 +287,9 @@ impl Tool { match layer_type { None | Some(LayerType::Vector) => Tool::all(), Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], - Some(LayerType::Raster) => &[Tool::Select, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper], + Some(LayerType::Raster) => &[Tool::Select, Tool::SelectLasso, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper], _ => &[Tool::Select], } } - /// Get keyboard shortcut hint - pub fn shortcut_hint(self) -> &'static str { - match self { - Tool::Select => "V", - Tool::Draw => "P", - Tool::Transform => "Q", - Tool::Rectangle => "R", - Tool::Ellipse => "E", - Tool::PaintBucket => "B", - Tool::Eyedropper => "I", - Tool::Line => "L", - Tool::Polygon => "G", - Tool::BezierEdit => "A", - Tool::Text => "T", - Tool::RegionSelect => "S", - Tool::Split => "C", - Tool::Erase => "X", - Tool::Smudge => "U", - } - } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs index 029c57b..8739fb0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -47,6 +47,7 @@ impl CustomCursor { 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 + Tool::SelectLasso => CustomCursor::Select, // Reuse select cursor for lasso } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index f3bcbe4..77a9716 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -94,6 +94,10 @@ pub enum AppAction { ToolBezierEdit, ToolText, ToolRegionSelect, + ToolErase, + ToolSmudge, + ToolSelectLasso, + ToolSplit, // === Global shortcuts === TogglePlayPause, @@ -142,7 +146,8 @@ impl AppAction { Self::ToolSelect | Self::ToolDraw | Self::ToolTransform | Self::ToolRectangle | Self::ToolEllipse | Self::ToolPaintBucket | Self::ToolEyedropper | Self::ToolLine | Self::ToolPolygon | - Self::ToolBezierEdit | Self::ToolText | Self::ToolRegionSelect => "Tools", + Self::ToolBezierEdit | Self::ToolText | Self::ToolRegionSelect | + Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools", Self::TogglePlayPause | Self::CancelAction | Self::ToggleDebugOverlay => "Global", @@ -234,6 +239,10 @@ impl AppAction { Self::ToolBezierEdit => "Bezier Edit Tool", Self::ToolText => "Text Tool", Self::ToolRegionSelect => "Region Select Tool", + Self::ToolErase => "Erase Tool", + Self::ToolSmudge => "Smudge Tool", + Self::ToolSelectLasso => "Lasso Select Tool", + Self::ToolSplit => "Split Tool", Self::TogglePlayPause => "Toggle Play/Pause", Self::CancelAction => "Cancel / Escape", Self::ToggleDebugOverlay => "Toggle Debug Overlay", @@ -271,6 +280,7 @@ impl AppAction { Self::ToolRectangle, Self::ToolEllipse, Self::ToolPaintBucket, Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon, Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect, + Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit, Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, #[cfg(debug_assertions)] Self::ToggleTestMode, @@ -415,6 +425,30 @@ impl AppAction { } } +/// Return the `AppAction` that activates the given tool, if one exists. +/// `Tool::Split` has no tool-shortcut action (it's triggered via the menu). +pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option { + use lightningbeam_core::tool::Tool; + Some(match tool { + Tool::Select => AppAction::ToolSelect, + Tool::Draw => AppAction::ToolDraw, + Tool::Transform => AppAction::ToolTransform, + Tool::Rectangle => AppAction::ToolRectangle, + Tool::Ellipse => AppAction::ToolEllipse, + Tool::PaintBucket => AppAction::ToolPaintBucket, + Tool::Eyedropper => AppAction::ToolEyedropper, + Tool::Line => AppAction::ToolLine, + Tool::Polygon => AppAction::ToolPolygon, + Tool::BezierEdit => AppAction::ToolBezierEdit, + Tool::Text => AppAction::ToolText, + Tool::RegionSelect => AppAction::ToolRegionSelect, + Tool::Erase => AppAction::ToolErase, + Tool::Smudge => AppAction::ToolSmudge, + Tool::SelectLasso => AppAction::ToolSelectLasso, + Tool::Split => AppAction::ToolSplit, + }) +} + // === Default bindings === /// Build the complete default bindings map from the current hardcoded shortcuts @@ -459,7 +493,11 @@ pub fn all_defaults() -> HashMap> { defaults.insert(AppAction::ToolPolygon, Some(Shortcut::new(ShortcutKey::G, nc, ns, na))); defaults.insert(AppAction::ToolBezierEdit, Some(Shortcut::new(ShortcutKey::A, nc, ns, na))); defaults.insert(AppAction::ToolText, Some(Shortcut::new(ShortcutKey::T, nc, ns, na))); - defaults.insert(AppAction::ToolRegionSelect, Some(Shortcut::new(ShortcutKey::S, nc, ns, na))); + defaults.insert(AppAction::ToolRegionSelect, Some(Shortcut::new(ShortcutKey::S, nc, ns, na))); + defaults.insert(AppAction::ToolErase, Some(Shortcut::new(ShortcutKey::X, nc, ns, na))); + defaults.insert(AppAction::ToolSmudge, Some(Shortcut::new(ShortcutKey::U, nc, ns, na))); + defaults.insert(AppAction::ToolSelectLasso, Some(Shortcut::new(ShortcutKey::F, nc, ns, na))); + defaults.insert(AppAction::ToolSplit, Some(Shortcut::new(ShortcutKey::C, nc, ns, na))); // Global shortcuts defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na))); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 728090c..f755a0d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -324,6 +324,7 @@ mod tool_icons { pub static SPLIT: &[u8] = include_bytes!("../../../src/assets/split.svg"); pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg"); pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg"); + pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg"); } /// Embedded focus icon SVGs @@ -395,6 +396,7 @@ impl ToolIconCache { Tool::Split => tool_icons::SPLIT, Tool::Erase => tool_icons::ERASE, Tool::Smudge => tool_icons::SMUDGE, + Tool::SelectLasso => tool_icons::LASSO, }; if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) { self.icons.insert(tool, texture); @@ -770,6 +772,8 @@ struct EditorApp { /// Count of in-flight graph preset loads — keeps the repaint loop alive /// until the audio thread sends GraphPresetLoaded events for all of them pending_graph_loads: std::sync::Arc, + /// Set by raster select tools when a new interaction requires committing the floating selection + commit_raster_floating_if_any: bool, /// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane next frame pending_node_group: bool, /// Set by MenuAction::Ungroup when focus is Nodes — consumed by node graph pane next frame @@ -1044,6 +1048,7 @@ impl EditorApp { audio_event_rx, audio_events_pending: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), pending_graph_loads: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), + commit_raster_floating_if_any: false, pending_node_group: false, pending_node_ungroup: false, audio_sample_rate, @@ -1806,11 +1811,175 @@ impl EditorApp { } } + // ----------------------------------------------------------------------- + // Raster pixel helpers + // ----------------------------------------------------------------------- + + /// Extract the pixels covered by `sel` from `raw_pixels`. + /// Returns (pixels, width, height) in sRGB-premul RGBA format. + /// For a Lasso selection pixels outside the polygon are zeroed (alpha=0). + fn extract_raster_selection( + raw_pixels: &[u8], + canvas_w: u32, + canvas_h: u32, + sel: &lightningbeam_core::selection::RasterSelection, + ) -> (Vec, u32, u32) { + use lightningbeam_core::selection::RasterSelection; + let (x0, y0, x1, y1) = sel.bounding_rect(); + let x0 = x0.max(0) as u32; + let y0 = y0.max(0) as u32; + let x1 = (x1 as u32).min(canvas_w); + let y1 = (y1 as u32).min(canvas_h); + let w = x1.saturating_sub(x0); + let h = y1.saturating_sub(y0); + let mut out = vec![0u8; (w * h * 4) as usize]; + for row in 0..h { + for col in 0..w { + let cx = x0 + col; + let cy = y0 + row; + let inside = match sel { + RasterSelection::Rect(..) => true, + RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32), + }; + if inside { + let src = ((cy * canvas_w + cx) * 4) as usize; + let dst = ((row * w + col) * 4) as usize; + out[dst..dst + 4].copy_from_slice(&raw_pixels[src..src + 4]); + } + } + } + (out, w, h) + } + + /// Erase pixels covered by `sel` in `raw_pixels` (set alpha=0, rgb=0). + fn erase_raster_selection( + raw_pixels: &mut [u8], + canvas_w: u32, + canvas_h: u32, + sel: &lightningbeam_core::selection::RasterSelection, + ) { + let (x0, y0, x1, y1) = sel.bounding_rect(); + let x0 = x0.max(0) as u32; + let y0 = y0.max(0) as u32; + let x1 = (x1 as u32).min(canvas_w); + let y1 = (y1 as u32).min(canvas_h); + for cy in y0..y1 { + for cx in x0..x1 { + if sel.contains_pixel(cx as i32, cy as i32) { + let idx = ((cy * canvas_w + cx) * 4) as usize; + raw_pixels[idx..idx + 4].fill(0); + } + } + } + } + + /// Porter-Duff "over" composite of `src` onto `dst` at canvas offset `(ox, oy)`. + /// Both buffers are sRGB-encoded premultiplied RGBA. + fn composite_over( + dst: &mut [u8], dst_w: u32, dst_h: u32, + src: &[u8], src_w: u32, src_h: u32, + ox: i32, oy: i32, + ) { + for row in 0..src_h { + let dy = oy + row as i32; + if dy < 0 || dy >= dst_h as i32 { continue; } + for col in 0..src_w { + let dx = ox + col as i32; + if dx < 0 || dx >= dst_w as i32 { continue; } + let si = ((row * src_w + col) * 4) as usize; + let di = ((dy as u32 * dst_w + dx as u32) * 4) as usize; + let sa = src[si + 3] as u32; + if sa == 0 { continue; } + let da = dst[di + 3] as u32; + // out_a = src_a + dst_a * (255 - src_a) / 255 + let out_a = sa + da * (255 - sa) / 255; + dst[di + 3] = out_a as u8; + if out_a > 0 { + for c in 0..3 { + // premul over: out = src + dst*(1-src_a/255) + // v is in [0, 255²], so one /255 brings it back to [0, 255] + let v = src[si + c] as u32 * 255 + + dst[di + c] as u32 * (255 - sa); + dst[di + c] = (v / 255).min(255) as u8; + } + } + } + } + } + + /// Commit a floating raster selection: composite it into the keyframe's + /// `raw_pixels` and record a `RasterStrokeAction` for undo. + /// Clears `selection.raster_floating` and `selection.raster_selection`. + /// No-op if there is no floating selection. + fn commit_raster_floating(&mut self) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterStrokeAction; + + let Some(float) = self.selection.raster_floating.take() else { return }; + self.selection.raster_selection = None; + + let document = self.action_executor.document_mut(); + let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; + let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; + + Self::composite_over( + &mut kf.raw_pixels, kf.width, kf.height, + &float.pixels, float.width, float.height, + float.x, float.y, + ); + let canvas_after = kf.raw_pixels.clone(); + let w = kf.width; + let h = kf.height; + + let action = RasterStrokeAction::new( + float.layer_id, float.time, + float.canvas_before, canvas_after, + w, h, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("commit_raster_floating: {}", e); + } + } + + /// Cancel a floating raster selection: restore the canvas from the + /// pre-cut/paste snapshot. No undo entry is created. + fn cancel_raster_floating(&mut self) { + use lightningbeam_core::layer::AnyLayer; + + let Some(float) = self.selection.raster_floating.take() else { return }; + self.selection.raster_selection = None; + + let document = self.action_executor.document_mut(); + let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; + let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; + kf.raw_pixels = float.canvas_before; + } + /// Copy the current selection to the clipboard fn clipboard_copy_selection(&mut self) { use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType}; use lightningbeam_core::layer::AnyLayer; + // Raster selection takes priority when on a raster layer + if let (Some(layer_id), Some(raster_sel)) = ( + self.active_layer_id, + self.selection.raster_selection.as_ref(), + ) { + let document = self.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(self.playback_time) { + let (pixels, w, h) = Self::extract_raster_selection( + &kf.raw_pixels, kf.width, kf.height, raster_sel, + ); + self.clipboard_manager.try_set_raster_image(&pixels, w, h); + self.clipboard_manager.copy(ClipboardContent::RasterPixels { + pixels, width: w, height: h, + }); + } + return; + } + } + // Check what's selected: clip instances take priority, then shapes if !self.selection.clip_instances().is_empty() { let active_layer_id = match self.active_layer_id { @@ -1883,6 +2052,43 @@ impl EditorApp { /// Delete the current selection (for cut and delete operations) fn clipboard_delete_selection(&mut self) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterStrokeAction; + + // Raster: commit any floating selection first, then erase the marquee region + if let (Some(layer_id), Some(raster_sel)) = ( + self.active_layer_id, + self.selection.raster_selection.clone(), + ) { + let document = self.action_executor.document(); + if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) { + // Committing a floating selection before erasing ensures any + // prior paste is baked in before we punch the new hole. + self.commit_raster_floating(); + + let document = self.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&layer_id) { + if let Some(kf) = rl.keyframe_at_mut(self.playback_time) { + let canvas_before = kf.raw_pixels.clone(); + Self::erase_raster_selection( + &mut kf.raw_pixels, kf.width, kf.height, &raster_sel, + ); + let canvas_after = kf.raw_pixels.clone(); + let w = kf.width; + let h = kf.height; + let action = RasterStrokeAction::new( + layer_id, self.playback_time, + canvas_before, canvas_after, w, h, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Raster erase failed: {}", e); + } + } + } + self.selection.raster_selection = None; + return; + } + } if !self.selection.clip_instances().is_empty() { let active_layer_id = match self.active_layer_id { @@ -1972,12 +2178,26 @@ impl EditorApp { use lightningbeam_core::clipboard::ClipboardContent; use lightningbeam_core::layer::AnyLayer; - let content = match self.clipboard_manager.paste() { - Some(c) => c, - None => return, - }; + // Resolve content from all sources: + // 1. Internal cache (ClipboardContent, any type) + // 2. System clipboard JSON (LIGHTNINGBEAM_CLIPBOARD: prefix) + // 3. System clipboard image — only attempted when the active layer is raster, + // since non-raster layers have no way to consume raw pixel data + let active_is_raster = self.active_layer_id + .and_then(|id| self.action_executor.document().get_layer(&id)) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); - // Regenerate IDs for the paste + let content = self.clipboard_manager.paste().or_else(|| { + if active_is_raster { + self.clipboard_manager.try_get_raster_image() + .map(|(pixels, width, height)| ClipboardContent::RasterPixels { pixels, width, height }) + } else { + None + } + }); + let Some(content) = content else { return }; + + // Regenerate IDs for the paste (no-op for RasterPixels) let (new_content, _id_map) = content.with_regenerated_ids(); match new_content { @@ -2099,6 +2319,48 @@ impl EditorApp { ClipboardContent::MidiNotes { .. } => { // MIDI notes are pasted directly in the piano roll pane, not here } + ClipboardContent::RasterPixels { pixels, width, height } => { + let Some(layer_id) = self.active_layer_id else { return }; + let document = self.action_executor.document(); + let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) else { return }; + let Some(kf) = rl.keyframe_at(self.playback_time) else { return }; + + // Paste position: top-left of the current raster selection if any, + // otherwise the canvas origin. + let (paste_x, paste_y) = self.selection.raster_selection + .as_ref() + .map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) }) + .unwrap_or((0, 0)); + + // Snapshot canvas before for undo on commit / restore on cancel. + let canvas_before = kf.raw_pixels.clone(); + let canvas_w = kf.width; + let canvas_h = kf.height; + drop(kf); // release immutable borrow before taking mutable + + // Commit any pre-existing floating selection before creating a new one. + self.commit_raster_floating(); + + use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection}; + self.selection.raster_floating = Some(RasterFloatingSelection { + pixels, + width, + height, + x: paste_x, + y: paste_y, + layer_id, + time: self.playback_time, + canvas_before, + }); + // Update the marquee to show the floating selection bounds. + self.selection.raster_selection = Some(RasterSelection::Rect( + paste_x, + paste_y, + paste_x + width as i32, + paste_y + height as i32, + )); + let _ = (canvas_w, canvas_h); // used only to satisfy borrow checker above + } } } @@ -5201,6 +5463,7 @@ impl eframe::App for EditorApp { pending_graph_loads: &self.pending_graph_loads, clipboard_consumed: &mut clipboard_consumed, keymap: &self.keymap, + commit_raster_floating_if_any: &mut self.commit_raster_floating_if_any, pending_node_group: &mut self.pending_node_group, pending_node_ungroup: &mut self.pending_node_ungroup, #[cfg(debug_assertions)] @@ -5483,6 +5746,11 @@ impl eframe::App for EditorApp { // Reset playback time to 0 when entering a clip self.playback_time = 0.0; } + if self.commit_raster_floating_if_any { + self.commit_raster_floating_if_any = false; + self.commit_raster_floating(); + } + if pending_exit_clip { if let Some(entry) = self.editing_context.pop() { self.selection.clear(); @@ -5592,6 +5860,10 @@ impl eframe::App for EditorApp { (AppAction::ToolBezierEdit, Tool::BezierEdit), (AppAction::ToolText, Tool::Text), (AppAction::ToolRegionSelect, Tool::RegionSelect), + (AppAction::ToolErase, Tool::Erase), + (AppAction::ToolSmudge, Tool::Smudge), + (AppAction::ToolSelectLasso, Tool::SelectLasso), + (AppAction::ToolSplit, Tool::Split), ]; for &(action, tool) in tool_map { if self.keymap.action_pressed(action, i) { @@ -5619,9 +5891,13 @@ impl eframe::App for EditorApp { } } - // Escape key: revert uncommitted region selection + // Escape key: cancel floating raster selection or revert uncommitted region selection if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) { - if self.region_selection.is_some() { + if self.selection.raster_floating.is_some() { + self.cancel_raster_floating(); + } else if self.selection.raster_selection.is_some() { + self.selection.raster_selection = None; + } else if self.region_selection.is_some() { Self::revert_region_selection( &mut self.region_selection, &mut self.action_executor, diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 2a3f105..aff7fa5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -44,6 +44,62 @@ pub enum ShortcutKey { } impl ShortcutKey { + /// Convert to the corresponding `egui::Key`. + /// + /// Note: we maintain our own `ShortcutKey` enum rather than using `egui::Key` directly + /// because `egui::Key` only implements `serde::{Serialize, Deserialize}` behind the + /// `serde` cargo feature, which we do not enable for egui. Enabling it would couple + /// our persisted config format to egui's internal variant names, which could change + /// between egui version upgrades and silently break user keybind files. `ShortcutKey` + /// gives us a stable, self-owned serialization surface. The tradeoff is this one + /// exhaustive mapping; the display and input-matching methods below both delegate to + /// `egui::Key` so there is no further duplication. + pub fn to_egui_key(self) -> egui::Key { + match self { + Self::A => egui::Key::A, Self::B => egui::Key::B, Self::C => egui::Key::C, + Self::D => egui::Key::D, Self::E => egui::Key::E, Self::F => egui::Key::F, + Self::G => egui::Key::G, Self::H => egui::Key::H, Self::I => egui::Key::I, + Self::J => egui::Key::J, Self::K => egui::Key::K, Self::L => egui::Key::L, + Self::M => egui::Key::M, Self::N => egui::Key::N, Self::O => egui::Key::O, + Self::P => egui::Key::P, Self::Q => egui::Key::Q, Self::R => egui::Key::R, + Self::S => egui::Key::S, Self::T => egui::Key::T, Self::U => egui::Key::U, + Self::V => egui::Key::V, Self::W => egui::Key::W, Self::X => egui::Key::X, + Self::Y => egui::Key::Y, Self::Z => egui::Key::Z, + Self::Num0 => egui::Key::Num0, Self::Num1 => egui::Key::Num1, + Self::Num2 => egui::Key::Num2, Self::Num3 => egui::Key::Num3, + Self::Num4 => egui::Key::Num4, Self::Num5 => egui::Key::Num5, + Self::Num6 => egui::Key::Num6, Self::Num7 => egui::Key::Num7, + Self::Num8 => egui::Key::Num8, Self::Num9 => egui::Key::Num9, + Self::F1 => egui::Key::F1, Self::F2 => egui::Key::F2, + Self::F3 => egui::Key::F3, Self::F4 => egui::Key::F4, + Self::F5 => egui::Key::F5, Self::F6 => egui::Key::F6, + Self::F7 => egui::Key::F7, Self::F8 => egui::Key::F8, + Self::F9 => egui::Key::F9, Self::F10 => egui::Key::F10, + Self::F11 => egui::Key::F11, Self::F12 => egui::Key::F12, + Self::ArrowUp => egui::Key::ArrowUp, Self::ArrowDown => egui::Key::ArrowDown, + Self::ArrowLeft => egui::Key::ArrowLeft, Self::ArrowRight => egui::Key::ArrowRight, + Self::Comma => egui::Key::Comma, Self::Minus => egui::Key::Minus, + Self::Equals => egui::Key::Equals, Self::Plus => egui::Key::Plus, + Self::BracketLeft => egui::Key::OpenBracket, + Self::BracketRight => egui::Key::CloseBracket, + Self::Semicolon => egui::Key::Semicolon, Self::Quote => egui::Key::Quote, + Self::Period => egui::Key::Period, Self::Slash => egui::Key::Slash, + Self::Backtick => egui::Key::Backtick, + Self::Space => egui::Key::Space, Self::Escape => egui::Key::Escape, + Self::Enter => egui::Key::Enter, Self::Tab => egui::Key::Tab, + Self::Backspace => egui::Key::Backspace, Self::Delete => egui::Key::Delete, + Self::Home => egui::Key::Home, Self::End => egui::Key::End, + Self::PageUp => egui::Key::PageUp, Self::PageDown => egui::Key::PageDown, + } + } + + /// Short human-readable name for this key (e.g. "A", "F1", "Delete"). + /// Delegates to `egui::Key::name()` so the strings stay consistent with + /// what egui itself would display. + pub fn display_name(self) -> &'static str { + self.to_egui_key().name() + } + /// Try to convert an egui Key to a ShortcutKey pub fn from_egui_key(key: egui::Key) -> Option { Some(match key { @@ -90,6 +146,16 @@ impl Shortcut { Self { key, ctrl, shift, alt } } + /// Short hint string suitable for tool tooltips (e.g. "F", "Ctrl+S"). + pub fn hint_text(&self) -> String { + let mut parts: Vec<&str> = Vec::new(); + if self.ctrl { parts.push("Ctrl"); } + if self.shift { parts.push("Shift"); } + if self.alt { parts.push("Alt"); } + parts.push(self.key.display_name()); + parts.join("+") + } + /// Convert to muda Accelerator pub fn to_muda_accelerator(&self) -> Accelerator { let mut modifiers = Modifiers::empty(); @@ -198,84 +264,7 @@ impl Shortcut { return false; } - // Check key - let key = match self.key { - ShortcutKey::A => egui::Key::A, - ShortcutKey::B => egui::Key::B, - ShortcutKey::C => egui::Key::C, - ShortcutKey::D => egui::Key::D, - ShortcutKey::E => egui::Key::E, - ShortcutKey::F => egui::Key::F, - ShortcutKey::G => egui::Key::G, - ShortcutKey::H => egui::Key::H, - ShortcutKey::I => egui::Key::I, - ShortcutKey::J => egui::Key::J, - ShortcutKey::K => egui::Key::K, - ShortcutKey::L => egui::Key::L, - ShortcutKey::M => egui::Key::M, - ShortcutKey::N => egui::Key::N, - ShortcutKey::O => egui::Key::O, - ShortcutKey::P => egui::Key::P, - ShortcutKey::Q => egui::Key::Q, - ShortcutKey::R => egui::Key::R, - ShortcutKey::S => egui::Key::S, - ShortcutKey::T => egui::Key::T, - ShortcutKey::U => egui::Key::U, - ShortcutKey::V => egui::Key::V, - ShortcutKey::W => egui::Key::W, - ShortcutKey::X => egui::Key::X, - ShortcutKey::Y => egui::Key::Y, - ShortcutKey::Z => egui::Key::Z, - ShortcutKey::Num0 => egui::Key::Num0, - ShortcutKey::Num1 => egui::Key::Num1, - ShortcutKey::Num2 => egui::Key::Num2, - ShortcutKey::Num3 => egui::Key::Num3, - ShortcutKey::Num4 => egui::Key::Num4, - ShortcutKey::Num5 => egui::Key::Num5, - ShortcutKey::Num6 => egui::Key::Num6, - ShortcutKey::Num7 => egui::Key::Num7, - ShortcutKey::Num8 => egui::Key::Num8, - ShortcutKey::Num9 => egui::Key::Num9, - ShortcutKey::F1 => egui::Key::F1, - ShortcutKey::F2 => egui::Key::F2, - ShortcutKey::F3 => egui::Key::F3, - ShortcutKey::F4 => egui::Key::F4, - ShortcutKey::F5 => egui::Key::F5, - ShortcutKey::F6 => egui::Key::F6, - ShortcutKey::F7 => egui::Key::F7, - ShortcutKey::F8 => egui::Key::F8, - ShortcutKey::F9 => egui::Key::F9, - ShortcutKey::F10 => egui::Key::F10, - ShortcutKey::F11 => egui::Key::F11, - ShortcutKey::F12 => egui::Key::F12, - ShortcutKey::ArrowUp => egui::Key::ArrowUp, - ShortcutKey::ArrowDown => egui::Key::ArrowDown, - ShortcutKey::ArrowLeft => egui::Key::ArrowLeft, - ShortcutKey::ArrowRight => egui::Key::ArrowRight, - ShortcutKey::Comma => egui::Key::Comma, - ShortcutKey::Minus => egui::Key::Minus, - ShortcutKey::Equals => egui::Key::Equals, - ShortcutKey::Plus => egui::Key::Plus, - ShortcutKey::BracketLeft => egui::Key::OpenBracket, - ShortcutKey::BracketRight => egui::Key::CloseBracket, - ShortcutKey::Semicolon => egui::Key::Semicolon, - ShortcutKey::Quote => egui::Key::Quote, - ShortcutKey::Period => egui::Key::Period, - ShortcutKey::Slash => egui::Key::Slash, - ShortcutKey::Backtick => egui::Key::Backtick, - ShortcutKey::Space => egui::Key::Space, - ShortcutKey::Escape => egui::Key::Escape, - ShortcutKey::Enter => egui::Key::Enter, - ShortcutKey::Tab => egui::Key::Tab, - ShortcutKey::Backspace => egui::Key::Backspace, - ShortcutKey::Delete => egui::Key::Delete, - ShortcutKey::Home => egui::Key::Home, - ShortcutKey::End => egui::Key::End, - ShortcutKey::PageUp => egui::Key::PageUp, - ShortcutKey::PageDown => egui::Key::PageDown, - }; - - input.key_pressed(key) + input.key_pressed(self.key.to_egui_key()) } } @@ -954,39 +943,7 @@ impl MenuSystem { parts.push("Alt"); } - let key_name = match shortcut.key { - ShortcutKey::A => "A", ShortcutKey::B => "B", ShortcutKey::C => "C", - ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::F => "F", - ShortcutKey::G => "G", ShortcutKey::H => "H", ShortcutKey::I => "I", - ShortcutKey::J => "J", ShortcutKey::K => "K", ShortcutKey::L => "L", - ShortcutKey::M => "M", ShortcutKey::N => "N", ShortcutKey::O => "O", - ShortcutKey::P => "P", ShortcutKey::Q => "Q", ShortcutKey::R => "R", - ShortcutKey::S => "S", ShortcutKey::T => "T", ShortcutKey::U => "U", - ShortcutKey::V => "V", ShortcutKey::W => "W", ShortcutKey::X => "X", - ShortcutKey::Y => "Y", ShortcutKey::Z => "Z", - ShortcutKey::Num0 => "0", ShortcutKey::Num1 => "1", ShortcutKey::Num2 => "2", - ShortcutKey::Num3 => "3", ShortcutKey::Num4 => "4", ShortcutKey::Num5 => "5", - ShortcutKey::Num6 => "6", ShortcutKey::Num7 => "7", ShortcutKey::Num8 => "8", - ShortcutKey::Num9 => "9", - ShortcutKey::F1 => "F1", ShortcutKey::F2 => "F2", ShortcutKey::F3 => "F3", - ShortcutKey::F4 => "F4", ShortcutKey::F5 => "F5", ShortcutKey::F6 => "F6", - ShortcutKey::F7 => "F7", ShortcutKey::F8 => "F8", ShortcutKey::F9 => "F9", - ShortcutKey::F10 => "F10", ShortcutKey::F11 => "F11", ShortcutKey::F12 => "F12", - ShortcutKey::ArrowUp => "Up", ShortcutKey::ArrowDown => "Down", - ShortcutKey::ArrowLeft => "Left", ShortcutKey::ArrowRight => "Right", - ShortcutKey::Comma => ",", ShortcutKey::Minus => "-", - ShortcutKey::Equals => "=", ShortcutKey::Plus => "+", - ShortcutKey::BracketLeft => "[", ShortcutKey::BracketRight => "]", - ShortcutKey::Semicolon => ";", ShortcutKey::Quote => "'", - ShortcutKey::Period => ".", ShortcutKey::Slash => "/", - ShortcutKey::Backtick => "`", - ShortcutKey::Space => "Space", ShortcutKey::Escape => "Esc", - ShortcutKey::Enter => "Enter", ShortcutKey::Tab => "Tab", - ShortcutKey::Backspace => "Backspace", ShortcutKey::Delete => "Del", - ShortcutKey::Home => "Home", ShortcutKey::End => "End", - ShortcutKey::PageUp => "PgUp", ShortcutKey::PageDown => "PgDn", - }; - parts.push(key_name); + parts.push(shortcut.key.display_name()); parts.join("+") } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 2dd6ce0..4370a4f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -276,6 +276,9 @@ pub struct SharedPaneState<'a> { pub clipboard_consumed: &'a mut bool, /// Remappable keyboard shortcut manager pub keymap: &'a crate::keymap::KeymapManager, + /// Set by raster selection tools when they need main to commit the floating + /// selection before starting a new interaction. + pub commit_raster_floating_if_any: &'a mut bool, /// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane pub pending_node_group: &'a mut bool, /// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 1bbc8fd..b7a25e1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4372,6 +4372,68 @@ impl StagePane { /// Handle raster stroke tool input (Draw/Erase/Smudge on a raster layer). /// /// Computes GPU dab lists for each drag event and stores them in + /// Commit any live floating raster selection into `raw_pixels` right now, + /// synchronously. Must be called before capturing `buffer_before` for a + /// new brush stroke or before starting a new marquee/lasso drag, so the + /// GPU canvas and undo snapshots are based on the fully-composited canvas. + /// + /// Unlike the async `commit_raster_floating_if_any` flag (used for tool + /// switches detected in main.rs), this path is needed for in-canvas + /// interactions where the commit must happen *before* other per-frame work. + fn commit_raster_floating_now(shared: &mut SharedPaneState) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterStrokeAction; + use lightningbeam_core::selection::RasterFloatingSelection; + + let Some(float): Option = + shared.selection.raster_floating.take() + else { + return; + }; + shared.selection.raster_selection = None; + + let document = shared.action_executor.document_mut(); + let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { + return; + }; + let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; + + // Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels. + for row in 0..float.height { + let dy = float.y + row as i32; + if dy < 0 || dy >= kf.height as i32 { continue; } + for col in 0..float.width { + let dx = float.x + col as i32; + if dx < 0 || dx >= kf.width as i32 { continue; } + let si = ((row * float.width + col) * 4) as usize; + let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize; + let sa = float.pixels[si + 3] as u32; + if sa == 0 { continue; } + let da = kf.raw_pixels[di + 3] as u32; + let out_a = sa + da * (255 - sa) / 255; + kf.raw_pixels[di + 3] = out_a as u8; + if out_a > 0 { + for c in 0..3 { + let v = float.pixels[si + c] as u32 * 255 + + kf.raw_pixels[di + c] as u32 * (255 - sa); + kf.raw_pixels[di + c] = (v / 255).min(255) as u8; + } + } + } + } + + let canvas_after = kf.raw_pixels.clone(); + let (w, h) = (kf.width, kf.height); + let action = RasterStrokeAction::new( + float.layer_id, float.time, + float.canvas_before, canvas_after, + w, h, + ); + if let Err(e) = shared.action_executor.execute(Box::new(action)) { + eprintln!("commit_raster_floating_now: {e}"); + } + } + /// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`. /// /// The actual pixel rendering happens on the GPU (compute shader). The CPU @@ -4428,6 +4490,10 @@ impl StagePane { // Mouse down: capture buffer_before, start stroke, compute first dab // ---------------------------------------------------------------- if self.rsp_drag_started(response) || self.rsp_clicked(response) { + // Commit any floating selection synchronously so buffer_before and + // the GPU canvas initial upload see the fully-composited canvas. + Self::commit_raster_floating_now(shared); + let (doc_width, doc_height) = { let doc = shared.action_executor.document(); (doc.width as u32, doc.height as u32) @@ -4609,6 +4675,181 @@ impl StagePane { } } + /// Rectangular marquee selection tool for raster layers. + fn handle_raster_select_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::selection::RasterSelection; + use lightningbeam_core::tool::ToolState; + + let Some(layer_id) = *shared.active_layer_id else { return }; + let doc = shared.action_executor.document(); + let Some(kf) = doc.get_layer(&layer_id).and_then(|l| { + if let AnyLayer::Raster(rl) = l { rl.keyframe_at(*shared.playback_time) } else { None } + }) else { return }; + let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32); + + if self.rsp_drag_started(response) { + Self::commit_raster_floating_now(shared); + let (px, py) = (world_pos.x as i32, world_pos.y as i32); + *shared.tool_state = ToolState::DrawingRasterMarquee { + start: (px, py), + current: (px, py), + }; + } + + if self.rsp_dragged(response) { + if let ToolState::DrawingRasterMarquee { start, ref mut current } = *shared.tool_state { + let (px, py) = (world_pos.x as i32, world_pos.y as i32); + *current = (px, py); + let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w)); + let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h)); + shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); + } + } + + if self.rsp_drag_stopped(response) { + if let ToolState::DrawingRasterMarquee { start, current } = *shared.tool_state { + let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w)); + let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h)); + shared.selection.raster_selection = if x1 > x0 && y1 > y0 { + Some(RasterSelection::Rect(x0, y0, x1, y1)) + } else { + None + }; + *shared.tool_state = ToolState::Idle; + } + } + + if self.rsp_clicked(response) { + // A click with no drag: commit float (clicked() fires on release, so + // drag_started() may not have fired) then clear the selection. + Self::commit_raster_floating_now(shared); + shared.selection.raster_selection = None; + *shared.tool_state = ToolState::Idle; + } + + let _ = (ui, canvas_h); + } + + /// Freehand lasso selection tool for raster layers. + fn handle_raster_lasso_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::selection::RasterSelection; + use lightningbeam_core::tool::ToolState; + + let Some(layer_id) = *shared.active_layer_id else { return }; + if !shared.action_executor.document() + .get_layer(&layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))) + { return; } + + if self.rsp_drag_started(response) { + Self::commit_raster_floating_now(shared); + let pt = (world_pos.x as i32, world_pos.y as i32); + *shared.tool_state = ToolState::DrawingRasterLasso { points: vec![pt] }; + } + + if self.rsp_dragged(response) { + if let ToolState::DrawingRasterLasso { ref mut points } = *shared.tool_state { + let pt = (world_pos.x as i32, world_pos.y as i32); + if let Some(&last) = points.last() { + let (dx, dy) = (pt.0 - last.0, pt.1 - last.1); + if dx * dx + dy * dy >= 9 { + points.push(pt); + } + } + if points.len() >= 2 { + shared.selection.raster_selection = Some(RasterSelection::Lasso(points.clone())); + } + } + } + + if self.rsp_drag_stopped(response) { + if let ToolState::DrawingRasterLasso { ref points } = *shared.tool_state { + shared.selection.raster_selection = if points.len() >= 3 { + Some(RasterSelection::Lasso(points.clone())) + } else { + None + }; + } + *shared.tool_state = ToolState::Idle; + } + + if self.rsp_clicked(response) { + shared.selection.raster_selection = None; + *shared.tool_state = ToolState::Idle; + } + + let _ = ui; + } + + /// Animated "marching ants" dashed outline along a closed screen-space polygon. + /// `phase` advances over time to animate the dashes. + fn draw_marching_ants(painter: &egui::Painter, pts: &[egui::Pos2], phase: f32) { + if pts.len() < 2 { return; } + let n = pts.len(); + let mut d = phase.rem_euclid(8.0); // 4px on, 4px off + for i in 0..n { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let seg = a.distance(b); + if seg < 0.5 { continue; } + let dir = (b - a) / seg; + let mut t = 0.0f32; + while t < seg { + let rem = if d < 4.0 { 4.0 - d } else { 8.0 - d }; + let dl = rem.min(seg - t); + if d < 4.0 { + let p0 = a + dir * t; + let p1 = a + dir * (t + dl); + painter.line_segment([p0, p1], egui::Stroke::new(2.5, egui::Color32::WHITE)); + painter.line_segment([p0, p1], egui::Stroke::new(1.5, egui::Color32::BLACK)); + } + d = (d + dl).rem_euclid(8.0); + t += dl; + } + } + } + + /// Draw marching ants around a canvas-space rect converted to screen space. + fn draw_marching_ants_rect( + painter: &egui::Painter, + rect_min: egui::Pos2, + x0: i32, y0: i32, x1: i32, y1: i32, + zoom: f32, pan: egui::Vec2, phase: f32, + ) { + let s = |cx: i32, cy: i32| egui::pos2( + rect_min.x + pan.x + cx as f32 * zoom, + rect_min.y + pan.y + cy as f32 * zoom, + ); + Self::draw_marching_ants(painter, &[s(x0,y0), s(x1,y0), s(x1,y1), s(x0,y1)], phase); + } + + /// Draw marching ants around a canvas-space lasso polygon. + fn draw_marching_ants_lasso( + painter: &egui::Painter, + rect_min: egui::Pos2, + pts: &[(i32, i32)], + zoom: f32, pan: egui::Vec2, phase: f32, + ) { + let screen: Vec = pts.iter().map(|&(cx, cy)| egui::pos2( + rect_min.x + pan.x + cx as f32 * zoom, + rect_min.y + pan.y + cy as f32 * zoom, + )).collect(); + Self::draw_marching_ants(painter, &screen, phase); + } + fn handle_paint_bucket_tool( &mut self, response: &egui::Response, @@ -6609,7 +6850,14 @@ impl StagePane { match *shared.selected_tool { Tool::Select => { - self.handle_select_tool(ui, &response, world_pos, shift_held, shared); + let is_raster = shared.active_layer_id.and_then(|id| { + shared.action_executor.document().get_layer(&id) + }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); + if is_raster { + self.handle_raster_select_tool(ui, &response, world_pos, shared); + } else { + self.handle_select_tool(ui, &response, world_pos, shift_held, shared); + } } Tool::BezierEdit => { self.handle_bezier_edit_tool(ui, &response, world_pos, shift_held, shared); @@ -6637,6 +6885,9 @@ impl StagePane { Tool::Smudge => { self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Smudge, shared); } + Tool::SelectLasso => { + self.handle_raster_lasso_tool(ui, &response, world_pos, shared); + } Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); } @@ -6956,6 +7207,79 @@ impl StagePane { } } + /// Render raster selection overlays: + /// - Animated "marching ants" around the active raster selection (marquee or lasso) + /// - Floating selection pixels as an egui texture composited at the float position + fn render_raster_selection_overlays( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::selection::RasterSelection; + + let has_sel = shared.selection.raster_selection.is_some(); + let has_float = shared.selection.raster_floating.is_some(); + if !has_sel && !has_float { return; } + + let time = ui.input(|i| i.time) as f32; + // 8px/s scroll rate → repeating every 1 s + let phase = (time * 8.0).rem_euclid(8.0); + let painter = ui.painter_at(rect); + let pan = self.pan_offset; + let zoom = self.zoom; + + // ── Marching ants ───────────────────────────────────────────────────── + if let Some(sel) = &shared.selection.raster_selection { + match sel { + RasterSelection::Rect(x0, y0, x1, y1) => { + Self::draw_marching_ants_rect( + &painter, rect.min, + *x0, *y0, *x1, *y1, + zoom, pan, phase, + ); + } + RasterSelection::Lasso(pts) => { + Self::draw_marching_ants_lasso(&painter, rect.min, pts, zoom, pan, phase); + } + } + } + + // ── Floating selection texture overlay ──────────────────────────────── + if let Some(float) = &shared.selection.raster_floating { + let tex_id = format!("raster_float_{}_{}", float.layer_id, float.time.to_bits()); + + // Upload pixels as an egui texture (re-uploaded every frame the float exists; + // egui caches by name so this is a no-op when the pixels haven't changed). + let color_image = egui::ColorImage::from_rgba_premultiplied( + [float.width as usize, float.height as usize], + &float.pixels, + ); + let texture = ui.ctx().load_texture( + &tex_id, + color_image, + egui::TextureOptions::NEAREST, + ); + + // Position in screen space + let sx = rect.min.x + pan.x + float.x as f32 * zoom; + let sy = rect.min.y + pan.y + float.y as f32 * zoom; + let sw = float.width as f32 * zoom; + let sh = float.height as f32 * zoom; + let float_rect = egui::Rect::from_min_size(egui::pos2(sx, sy), egui::vec2(sw, sh)); + + painter.image( + texture.id(), + float_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + + // Keep animating while a selection is visible + ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); + } + /// Render snap indicator when snap is active (works for all vector-editing tools). /// Also computes hover snap when idle (no active drag snap) so the user can /// preview snap targets before clicking. @@ -7554,6 +7878,9 @@ impl PaneRenderer for StagePane { // Render vector editing overlays (vertices, control points, etc.) self.render_vector_editing_overlays(ui, rect, shared); + // Raster selection overlays: marching ants + floating selection texture + self.render_raster_selection_overlays(ui, rect, shared); + // Render snap indicator (works for all tools, not just Select/BezierEdit) self.render_snap_indicator(ui, rect, shared); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index d82b70f..254c050 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -6,6 +6,7 @@ use eframe::egui; use lightningbeam_core::layer::{AnyLayer, LayerType}; use lightningbeam_core::tool::{Tool, RegionSelectMode}; +use crate::keymap::tool_app_action; use super::{NodePath, PaneRenderer, SharedPaneState}; /// Toolbar pane state @@ -163,15 +164,20 @@ impl PaneRenderer for ToolbarPane { ); } - // Show tooltip with tool name and shortcut (consumes response) + // Show tooltip with tool name and shortcut (consumes response). + // Hint text is pulled from the live keymap so it reflects user remappings. + let hint = tool_app_action(*tool) + .and_then(|action| shared.keymap.get(action)) + .map(|s| format!(" ({})", s.hint_text())) + .unwrap_or_default(); let tooltip = if *tool == Tool::RegionSelect { let mode = match *shared.region_select_mode { RegionSelectMode::Rectangle => "Rectangle", RegionSelectMode::Lasso => "Lasso", }; - format!("{} - {} ({})\nRight-click for options", tool.display_name(), mode, tool.shortcut_hint()) + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) } else { - format!("{} ({})", tool.display_name(), tool.shortcut_hint()) + format!("{}{}", tool.display_name(), hint) }; response.on_hover_text(tooltip); diff --git a/src/assets/lasso.svg b/src/assets/lasso.svg new file mode 100644 index 0000000..821cece --- /dev/null +++ b/src/assets/lasso.svg @@ -0,0 +1,6 @@ + + + + + +