From 73ef9e3b9ce9949fa28a7d8a8121631c7db29f8f Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 2 Mar 2026 09:19:55 -0500 Subject: [PATCH] fix double paste and make selections always floating --- .../lightningbeam-core/src/tool.rs | 7 + .../lightningbeam-editor/src/main.rs | 98 +++++++-- .../lightningbeam-editor/src/panes/stage.rs | 188 +++++++++++++++--- 3 files changed, 244 insertions(+), 49 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index f79f547..d70f9ea 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -88,6 +88,13 @@ pub enum ToolState { current: (i32, i32), }, + /// Moving an existing raster selection (and its floating pixels, if any). + MovingRasterSelection { + /// Canvas position of the pointer at the last processed event, used to + /// compute per-frame deltas. + last: (i32, i32), + }, + /// Dragging selected objects DraggingSelection { start_pos: Point, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 77325cf..2ce9e91 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1963,27 +1963,62 @@ impl EditorApp { kf.raw_pixels = float.canvas_before; } + /// Drop (discard) the floating selection keeping the hole punched in the + /// canvas. Records a `RasterStrokeAction` for undo. Used by cut (Ctrl+X). + fn drop_raster_float(&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 doc = self.action_executor.document_mut(); + let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&float.layer_id) else { return }; + let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; + // raw_pixels already has the hole; record the undo action. + 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) = self.action_executor.execute(Box::new(action)) { + eprintln!("drop_raster_float: {e}"); + } + } + /// 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(), - ) { + // Raster selection takes priority when on a raster layer. + // If a floating selection exists (auto-lifted pixels), read directly from + // the float so we get exactly the lifted pixels. + if let Some(layer_id) = self.active_layer_id { 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, - ); + if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) { + if let Some(float) = &self.selection.raster_floating { self.clipboard_manager.copy(ClipboardContent::RasterPixels { - pixels, width: w, height: h, + pixels: float.pixels.clone(), + width: float.width, + height: float.height, }); + return; + } else if let Some(raster_sel) = self.selection.raster_selection.as_ref() { + 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.copy(ClipboardContent::RasterPixels { + pixels, width: w, height: h, + }); + } + } + return; } - return; } } @@ -2062,15 +2097,24 @@ impl EditorApp { use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::actions::RasterStrokeAction; - // Raster: commit any floating selection first, then erase the marquee region + // Raster: if a floating selection exists (auto-lifted), just drop it + // (keeps the hole). Otherwise commit any float then erase the marquee region. + if let Some(layer_id) = self.active_layer_id { + let document = self.action_executor.document(); + if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) { + if self.selection.raster_floating.is_some() { + self.drop_raster_float(); + return; + } + } + } + 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(); @@ -2314,6 +2358,13 @@ impl EditorApp { } ClipboardContent::RasterPixels { pixels, width, height } => { let Some(layer_id) = self.active_layer_id else { return }; + + // Commit any pre-existing floating selection FIRST so that + // canvas_before captures the fully-composited state (not the + // pre-commit state, which would corrupt the undo snapshot). + self.commit_raster_floating(); + + // Re-borrow the document after commit to get post-commit state. let document = self.action_executor.document(); let layer = document.get_layer(&layer_id); let Some(AnyLayer::Raster(rl)) = layer else { return }; @@ -2326,15 +2377,12 @@ impl EditorApp { .map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) }) .unwrap_or((0, 0)); - // Snapshot canvas before for undo on commit / restore on cancel. + // Snapshot canvas AFTER commit for correct 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, @@ -5821,17 +5869,21 @@ impl eframe::App for EditorApp { // Event::Copy/Cut/Paste instead of regular key events, so // check_shortcuts won't see them via key_pressed(). // Skip if a pane (e.g. piano roll) already handled the clipboard event. + let mut clipboard_handled = clipboard_consumed; if !clipboard_consumed { for event in &i.events { match event { egui::Event::Copy => { self.handle_menu_action(MenuAction::Copy); + clipboard_handled = true; } egui::Event::Cut => { self.handle_menu_action(MenuAction::Cut); + clipboard_handled = true; } egui::Event::Paste(_) => { self.handle_menu_action(MenuAction::Paste); + clipboard_handled = true; } // When text/plain is absent from the system clipboard egui-winit // falls through to a Key event instead of Event::Paste. @@ -5842,6 +5894,7 @@ impl eframe::App for EditorApp { .. } if modifiers.ctrl || modifiers.command => { self.handle_menu_action(MenuAction::Paste); + clipboard_handled = true; } _ => {} } @@ -5850,10 +5903,15 @@ impl eframe::App for EditorApp { // Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing // But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano) + // Also skip clipboard actions (Copy/Cut/Paste) if already handled above to prevent + // double-firing when egui emits both Event::Key{V} and key_pressed(V) is true. if let Some(action) = MenuSystem::check_shortcuts(i, Some(&self.keymap)) { + let is_clipboard = matches!(action, MenuAction::Copy | MenuAction::Cut | MenuAction::Paste); // Only trigger if keyboard isn't claimed OR the shortcut uses modifiers if !wants_keyboard || i.modifiers.ctrl || i.modifiers.command || i.modifiers.alt || i.modifiers.shift { - self.handle_menu_action(action); + if !(is_clipboard && clipboard_handled) { + self.handle_menu_action(action); + } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index b5d581a..9602b87 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2284,6 +2284,10 @@ pub struct StagePane { /// and updating raw_pixels, so the canvas lives one extra composite frame to /// avoid a flash of the stale Vello scene. pending_canvas_removal: Option, + /// Selection outline saved at stroke mouse-down for post-readback pixel masking. + /// Pixels outside the selection are restored from `buffer_before` so strokes + /// only affect the area inside the selection outline. + stroke_clip_selection: Option, /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2405,6 +2409,7 @@ impl StagePane { pending_undo_before: None, painting_canvas: None, pending_canvas_removal: None, + stroke_clip_selection: None, #[cfg(debug_assertions)] replay_override: None, } @@ -4434,6 +4439,63 @@ impl StagePane { } } + /// Lift the pixels enclosed by the current `raster_selection` into a + /// `RasterFloatingSelection`, punching a transparent hole in `raw_pixels`. + /// + /// Call this immediately after a marquee / lasso selection is finalized so + /// that all downstream operations (drag-move, copy, cut, stroke-masking) + /// see a consistent `raster_floating` whenever a selection is active. + fn lift_selection_to_float(shared: &mut SharedPaneState) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::selection::RasterFloatingSelection; + + // Clone the selection before any mutable borrows. + let Some(sel) = shared.selection.raster_selection.clone() else { return }; + let Some(layer_id) = *shared.active_layer_id else { return }; + let time = *shared.playback_time; + + // Commit any existing float first (clears raster_selection — re-set below). + Self::commit_raster_floating_now(shared); + + let doc = shared.action_executor.document_mut(); + let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) else { return }; + let Some(kf) = rl.keyframe_at_mut(time) else { return }; + + let canvas_before = kf.raw_pixels.clone(); + let (x0, y0, x1, y1) = sel.bounding_rect(); + let w = (x1 - x0).max(0) as u32; + let h = (y1 - y0).max(0) as u32; + if w == 0 || h == 0 { return; } + + let mut float_pixels = vec![0u8; (w * h * 4) as usize]; + for row in 0..h { + let sy = y0 + row as i32; + if sy < 0 || sy >= kf.height as i32 { continue; } + for col in 0..w { + let sx = x0 + col as i32; + if sx < 0 || sx >= kf.width as i32 { continue; } + if !sel.contains_pixel(sx, sy) { continue; } + let si = ((sy as u32 * kf.width + sx as u32) * 4) as usize; + let di = ((row * w + col) * 4) as usize; + float_pixels[di..di + 4].copy_from_slice(&kf.raw_pixels[si..si + 4]); + kf.raw_pixels[si..si + 4].fill(0); + } + } + + // Re-set selection (commit_raster_floating_now cleared it) and create float. + shared.selection.raster_selection = Some(sel); + shared.selection.raster_floating = Some(RasterFloatingSelection { + pixels: float_pixels, + width: w, + height: h, + x: x0, + y: y0, + layer_id, + time, + canvas_before, + }); + } + /// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`. /// /// The actual pixel rendering happens on the GPU (compute shader). The CPU @@ -4490,6 +4552,10 @@ impl StagePane { // Mouse down: capture buffer_before, start stroke, compute first dab // ---------------------------------------------------------------- if self.rsp_drag_started(response) || self.rsp_clicked(response) { + // Save selection BEFORE commit clears it — used after readback to + // mask the stroke result so only pixels inside the outline change. + self.stroke_clip_selection = shared.selection.raster_selection.clone(); + // 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); @@ -4695,42 +4761,88 @@ impl StagePane { 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), - }; + let inside = shared.selection.raster_selection + .as_ref() + .map_or(false, |sel| sel.contains_pixel(px, py)); + + if inside { + // Drag inside the selection — move it (and any floating pixels). + // As a safety net, lift the selection if no float exists yet. + if shared.selection.raster_floating.is_none() { + Self::lift_selection_to_float(shared); + } + *shared.tool_state = ToolState::MovingRasterSelection { last: (px, py) }; + } else { + // Drag outside — start a new marquee (commit any floating first). + Self::commit_raster_floating_now(shared); + *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)); + let (px, py) = (world_pos.x as i32, world_pos.y as i32); + match *shared.tool_state { + ToolState::DrawingRasterMarquee { start, ref mut current } => { + *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)); + } + ToolState::MovingRasterSelection { ref mut last } => { + let (dx, dy) = (px - last.0, py - last.1); + *last = (px, py); + // Shift the marquee. + if let Some(ref mut sel) = shared.selection.raster_selection { + *sel = match sel { + RasterSelection::Rect(x0, y0, x1, y1) => + RasterSelection::Rect(*x0 + dx, *y0 + dy, *x1 + dx, *y1 + dy), + RasterSelection::Lasso(pts) => + RasterSelection::Lasso(pts.iter().map(|(x, y)| (x + dx, y + dy)).collect()), + }; + } + // Shift floating pixels if any. + if let Some(ref mut float) = shared.selection.raster_floating { + float.x += dx; + float.y += dy; + } + } + _ => {} } } 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; + match *shared.tool_state { + ToolState::DrawingRasterMarquee { start, current } => { + 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)); + if x1 > x0 && y1 > y0 { + shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); + Self::lift_selection_to_float(shared); + } else { + shared.selection.raster_selection = None; + } + } + ToolState::MovingRasterSelection { .. } => {} + _ => {} } + *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; + // A click with no drag: if outside the selection, commit any float and + // clear; if inside, do nothing (preserves the selection). + let (px, py) = (world_pos.x as i32, world_pos.y as i32); + let inside = shared.selection.raster_selection + .as_ref() + .map_or(false, |sel| sel.contains_pixel(px, py)); + if !inside { + Self::commit_raster_floating_now(shared); + shared.selection.raster_selection = None; + } *shared.tool_state = ToolState::Idle; } @@ -4778,11 +4890,12 @@ impl StagePane { 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())) + if points.len() >= 3 { + shared.selection.raster_selection = Some(RasterSelection::Lasso(points.clone())); + Self::lift_selection_to_float(shared); } else { - None - }; + shared.selection.raster_selection = None; + } } *shared.tool_state = ToolState::Idle; } @@ -7427,11 +7540,28 @@ impl PaneRenderer for StagePane { if let Some(readback) = results.remove(&self.instance_id) { if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() { use lightningbeam_core::actions::RasterStrokeAction; + // If a selection was active at stroke-start, restore any pixels + // outside the selection outline to their pre-stroke values. + let canvas_after = match self.stroke_clip_selection.take() { + None => readback.pixels, + Some(sel) => { + let mut masked = readback.pixels; + for y in 0..h { + for x in 0..w { + if !sel.contains_pixel(x as i32, y as i32) { + let i = ((y * w + x) * 4) as usize; + masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]); + } + } + } + masked + } + }; let action = RasterStrokeAction::new( layer_id, time, buffer_before, - readback.pixels.clone(), + canvas_after, w, h, );