Compare commits

...

2 Commits

Author SHA1 Message Date
Skyler Lehmkuhl a45d674ed7 Merge branch 'rust-ui' of /home/skyler/Dev/Lightningbeam-2/. into rust-ui 2026-03-02 00:01:35 -05:00
Skyler Lehmkuhl 87815fe379 Cut/copy/paste raster data 2026-03-02 00:01:18 -05:00
11 changed files with 911 additions and 145 deletions

View File

@ -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<u8>,
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<Shape> = 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<u8> = 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<u8>, 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<u8> = 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() {

View File

@ -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<u8>,
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<u8>,
}
/// 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<Uuid>,
/// Active raster pixel selection (marquee or lasso outline).
/// Transient UI state — not persisted.
#[serde(skip)]
pub raster_selection: Option<RasterSelection>,
/// Floating raster selection waiting to be committed or cancelled.
/// Transient UI state — not persisted.
#[serde(skip)]
pub raster_floating: Option<RasterFloatingSelection>,
}
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

View File

@ -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<crate::raster_layer::StrokePoint>,
},
/// 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",
}
}
}

View File

@ -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
}
}

View File

@ -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<AppAction> {
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<AppAction, Option<Shortcut>> {
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)));

View File

@ -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);
@ -771,6 +773,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<std::sync::atomic::AtomicU32>,
/// 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
@ -1046,6 +1050,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,
@ -1808,11 +1813,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<u8>, 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 {
@ -1885,6 +2054,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 {
@ -1974,12 +2180,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 {
@ -2101,6 +2321,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
}
}
}
@ -5204,6 +5466,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)]
@ -5486,6 +5749,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();
@ -5595,6 +5863,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) {
@ -5622,9 +5894,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,

View File

@ -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<Self> {
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("+")
}

View File

@ -278,6 +278,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

View File

@ -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<RasterFloatingSelection> =
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<egui::Pos2> = 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);

View File

@ -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);

6
src/assets/lasso.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- Lasso loop -->
<path d="M12 4 C7 4, 3 7, 3 11 C3 15, 6 17, 10 17 C12 17, 14 16, 15 14 C16 12, 15.5 10, 14 9 C12.5 8, 10 8.5, 9 10 C8 11.5, 8.5 13, 10 13.5 C11.5 14, 13 13, 13 11.5 C13 10, 11.5 9.5, 10.5 10.5"/>
<!-- Handle/tail -->
<path d="M15.5 14.5 L19 20"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B