fix double paste and make selections always floating

This commit is contained in:
Skyler Lehmkuhl 2026-03-02 09:19:55 -05:00
parent 6f1a706dd2
commit 73ef9e3b9c
3 changed files with 244 additions and 49 deletions

View File

@ -88,6 +88,13 @@ pub enum ToolState {
current: (i32, i32), 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 /// Dragging selected objects
DraggingSelection { DraggingSelection {
start_pos: Point, start_pos: Point,

View File

@ -1963,27 +1963,62 @@ impl EditorApp {
kf.raw_pixels = float.canvas_before; 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 /// Copy the current selection to the clipboard
fn clipboard_copy_selection(&mut self) { fn clipboard_copy_selection(&mut self) {
use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType}; use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType};
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
// Raster selection takes priority when on a raster layer // Raster selection takes priority when on a raster layer.
if let (Some(layer_id), Some(raster_sel)) = ( // If a floating selection exists (auto-lifted pixels), read directly from
self.active_layer_id, // the float so we get exactly the lifted pixels.
self.selection.raster_selection.as_ref(), if let Some(layer_id) = self.active_layer_id {
) {
let document = self.action_executor.document(); let document = self.action_executor.document();
if let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) { if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
if let Some(kf) = rl.keyframe_at(self.playback_time) { if let Some(float) = &self.selection.raster_floating {
let (pixels, w, h) = Self::extract_raster_selection(
&kf.raw_pixels, kf.width, kf.height, raster_sel,
);
self.clipboard_manager.copy(ClipboardContent::RasterPixels { 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::layer::AnyLayer;
use lightningbeam_core::actions::RasterStrokeAction; 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)) = ( if let (Some(layer_id), Some(raster_sel)) = (
self.active_layer_id, self.active_layer_id,
self.selection.raster_selection.clone(), self.selection.raster_selection.clone(),
) { ) {
let document = self.action_executor.document(); let document = self.action_executor.document();
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) { 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(); self.commit_raster_floating();
let document = self.action_executor.document_mut(); let document = self.action_executor.document_mut();
@ -2314,6 +2358,13 @@ impl EditorApp {
} }
ClipboardContent::RasterPixels { pixels, width, height } => { ClipboardContent::RasterPixels { pixels, width, height } => {
let Some(layer_id) = self.active_layer_id else { return }; 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 document = self.action_executor.document();
let layer = document.get_layer(&layer_id); let layer = document.get_layer(&layer_id);
let Some(AnyLayer::Raster(rl)) = layer else { return }; let Some(AnyLayer::Raster(rl)) = layer else { return };
@ -2326,15 +2377,12 @@ impl EditorApp {
.map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) }) .map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) })
.unwrap_or((0, 0)); .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_before = kf.raw_pixels.clone();
let canvas_w = kf.width; let canvas_w = kf.width;
let canvas_h = kf.height; let canvas_h = kf.height;
drop(kf); // release immutable borrow before taking mutable 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}; use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
self.selection.raster_floating = Some(RasterFloatingSelection { self.selection.raster_floating = Some(RasterFloatingSelection {
pixels, pixels,
@ -5821,17 +5869,21 @@ impl eframe::App for EditorApp {
// Event::Copy/Cut/Paste instead of regular key events, so // Event::Copy/Cut/Paste instead of regular key events, so
// check_shortcuts won't see them via key_pressed(). // check_shortcuts won't see them via key_pressed().
// Skip if a pane (e.g. piano roll) already handled the clipboard event. // Skip if a pane (e.g. piano roll) already handled the clipboard event.
let mut clipboard_handled = clipboard_consumed;
if !clipboard_consumed { if !clipboard_consumed {
for event in &i.events { for event in &i.events {
match event { match event {
egui::Event::Copy => { egui::Event::Copy => {
self.handle_menu_action(MenuAction::Copy); self.handle_menu_action(MenuAction::Copy);
clipboard_handled = true;
} }
egui::Event::Cut => { egui::Event::Cut => {
self.handle_menu_action(MenuAction::Cut); self.handle_menu_action(MenuAction::Cut);
clipboard_handled = true;
} }
egui::Event::Paste(_) => { egui::Event::Paste(_) => {
self.handle_menu_action(MenuAction::Paste); self.handle_menu_action(MenuAction::Paste);
clipboard_handled = true;
} }
// When text/plain is absent from the system clipboard egui-winit // When text/plain is absent from the system clipboard egui-winit
// falls through to a Key event instead of Event::Paste. // falls through to a Key event instead of Event::Paste.
@ -5842,6 +5894,7 @@ impl eframe::App for EditorApp {
.. ..
} if modifiers.ctrl || modifiers.command => { } if modifiers.ctrl || modifiers.command => {
self.handle_menu_action(MenuAction::Paste); 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 // 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) // 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)) { 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 // 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 { 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);
}
} }
} }

View File

@ -2284,6 +2284,10 @@ pub struct StagePane {
/// and updating raw_pixels, so the canvas lives one extra composite frame to /// and updating raw_pixels, so the canvas lives one extra composite frame to
/// avoid a flash of the stale Vello scene. /// avoid a flash of the stale Vello scene.
pending_canvas_removal: Option<uuid::Uuid>, pending_canvas_removal: Option<uuid::Uuid>,
/// 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<lightningbeam_core::selection::RasterSelection>,
/// Synthetic drag/click override for test mode replay (debug builds only) /// Synthetic drag/click override for test mode replay (debug builds only)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
replay_override: Option<ReplayDragState>, replay_override: Option<ReplayDragState>,
@ -2405,6 +2409,7 @@ impl StagePane {
pending_undo_before: None, pending_undo_before: None,
painting_canvas: None, painting_canvas: None,
pending_canvas_removal: None, pending_canvas_removal: None,
stroke_clip_selection: None,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
replay_override: None, 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()`. /// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`.
/// ///
/// The actual pixel rendering happens on the GPU (compute shader). The CPU /// 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 // Mouse down: capture buffer_before, start stroke, compute first dab
// ---------------------------------------------------------------- // ----------------------------------------------------------------
if self.rsp_drag_started(response) || self.rsp_clicked(response) { 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 // Commit any floating selection synchronously so buffer_before and
// the GPU canvas initial upload see the fully-composited canvas. // the GPU canvas initial upload see the fully-composited canvas.
Self::commit_raster_floating_now(shared); 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); let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32);
if self.rsp_drag_started(response) { 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); let (px, py) = (world_pos.x as i32, world_pos.y as i32);
*shared.tool_state = ToolState::DrawingRasterMarquee { let inside = shared.selection.raster_selection
start: (px, py), .as_ref()
current: (px, py), .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 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);
let (px, py) = (world_pos.x as i32, world_pos.y as i32); match *shared.tool_state {
*current = (px, py); ToolState::DrawingRasterMarquee { start, ref mut current } => {
let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w)); *current = (px, py);
let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h)); let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w));
shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1)); 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 self.rsp_drag_stopped(response) {
if let ToolState::DrawingRasterMarquee { start, current } = *shared.tool_state { match *shared.tool_state {
let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w)); ToolState::DrawingRasterMarquee { start, current } => {
let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h)); let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w));
shared.selection.raster_selection = if x1 > x0 && y1 > y0 { let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h));
Some(RasterSelection::Rect(x0, y0, x1, y1)) if x1 > x0 && y1 > y0 {
} else { shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1));
None Self::lift_selection_to_float(shared);
}; } else {
*shared.tool_state = ToolState::Idle; shared.selection.raster_selection = None;
}
}
ToolState::MovingRasterSelection { .. } => {}
_ => {}
} }
*shared.tool_state = ToolState::Idle;
} }
if self.rsp_clicked(response) { if self.rsp_clicked(response) {
// A click with no drag: commit float (clicked() fires on release, so // A click with no drag: if outside the selection, commit any float and
// drag_started() may not have fired) then clear the selection. // clear; if inside, do nothing (preserves the selection).
Self::commit_raster_floating_now(shared); let (px, py) = (world_pos.x as i32, world_pos.y as i32);
shared.selection.raster_selection = None; 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; *shared.tool_state = ToolState::Idle;
} }
@ -4778,11 +4890,12 @@ impl StagePane {
if self.rsp_drag_stopped(response) { if self.rsp_drag_stopped(response) {
if let ToolState::DrawingRasterLasso { ref points } = *shared.tool_state { if let ToolState::DrawingRasterLasso { ref points } = *shared.tool_state {
shared.selection.raster_selection = if points.len() >= 3 { if points.len() >= 3 {
Some(RasterSelection::Lasso(points.clone())) shared.selection.raster_selection = Some(RasterSelection::Lasso(points.clone()));
Self::lift_selection_to_float(shared);
} else { } else {
None shared.selection.raster_selection = None;
}; }
} }
*shared.tool_state = ToolState::Idle; *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(readback) = results.remove(&self.instance_id) {
if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() { if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() {
use lightningbeam_core::actions::RasterStrokeAction; 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( let action = RasterStrokeAction::new(
layer_id, layer_id,
time, time,
buffer_before, buffer_before,
readback.pixels.clone(), canvas_after,
w, w,
h, h,
); );