Compare commits

...

5 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 73ef9e3b9c fix double paste and make selections always floating 2026-03-02 09:19:55 -05:00
Skyler Lehmkuhl 6f1a706dd2 fix interaction with a fresh raster layer 2026-03-02 08:07:45 -05:00
Skyler Lehmkuhl 19617e4223 fix pasting image data from external programs 2026-03-02 07:59:29 -05:00
Skyler Lehmkuhl c1266c0377 remove legacy path that was still dumping into text clipboard 2026-03-02 07:54:14 -05:00
Skyler Lehmkuhl 75e35b0ac6 Don't dump json into text clipboard 2026-03-02 07:30:09 -05:00
8 changed files with 1259 additions and 275 deletions

View File

@ -2258,6 +2258,12 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flacenc" name = "flacenc"
version = "0.4.0" version = "0.4.0"
@ -3443,6 +3449,9 @@ dependencies = [
"image", "image",
"kurbo 0.12.0", "kurbo 0.12.0",
"lru", "lru",
"objc2 0.6.3",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pathdiff", "pathdiff",
"rstar", "rstar",
"serde", "serde",
@ -3451,6 +3460,9 @@ dependencies = [
"uuid", "uuid",
"vello", "vello",
"wgpu", "wgpu",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11-clipboard",
"zip", "zip",
] ]
@ -4030,7 +4042,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [ dependencies = [
"proc-macro-crate 2.0.2", "proc-macro-crate 3.4.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.110", "syn 2.0.110",
@ -4080,10 +4092,10 @@ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"libc", "libc",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-core-data", "objc2-core-data 0.2.2",
"objc2-core-image", "objc2-core-image 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-quartz-core", "objc2-quartz-core 0.2.2",
] ]
[[package]] [[package]]
@ -4094,10 +4106,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2 0.6.2", "block2 0.6.2",
"libc",
"objc2 0.6.3", "objc2 0.6.3",
"objc2-cloud-kit 0.3.2",
"objc2-core-data 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
"objc2-core-image 0.3.2",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
] ]
[[package]] [[package]]
@ -4138,6 +4157,17 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-contacts" name = "objc2-contacts"
version = "0.2.2" version = "0.2.2"
@ -4184,6 +4214,17 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-core-foundation" name = "objc2-core-foundation"
version = "0.3.2" version = "0.3.2"
@ -4222,6 +4263,16 @@ dependencies = [
"objc2-metal", "objc2-metal",
] ]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-core-location" name = "objc2-core-location"
version = "0.2.2" version = "0.2.2"
@ -4234,6 +4285,31 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@ -4314,6 +4390,17 @@ dependencies = [
"objc2-metal", "objc2-metal",
] ]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-symbols" name = "objc2-symbols"
version = "0.2.2" version = "0.2.2"
@ -4333,13 +4420,13 @@ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-cloud-kit", "objc2-cloud-kit 0.2.2",
"objc2-core-data", "objc2-core-data 0.2.2",
"objc2-core-image", "objc2-core-image 0.2.2",
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-link-presentation", "objc2-link-presentation",
"objc2-quartz-core", "objc2-quartz-core 0.2.2",
"objc2-symbols", "objc2-symbols",
"objc2-uniform-type-identifiers", "objc2-uniform-type-identifiers",
"objc2-user-notifications", "objc2-user-notifications",
@ -4415,6 +4502,16 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "outref" name = "outref"
version = "0.1.0" version = "0.1.0"
@ -4593,6 +4690,17 @@ dependencies = [
"indexmap 2.12.0", "indexmap 2.12.0",
] ]
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap 2.12.0",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.11.3" version = "0.11.3"
@ -6414,6 +6522,17 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph 0.8.3",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.21.1" version = "0.21.1"
@ -7711,6 +7830,24 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.2",
"thiserror 2.0.17",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.2" version = "0.6.2"
@ -7736,6 +7873,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11-clipboard"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
dependencies = [
"libc",
"x11rb",
]
[[package]] [[package]]
name = "x11-dl" name = "x11-dl"
version = "2.21.0" version = "2.21.0"

View File

@ -47,5 +47,24 @@ rstar = "0.12"
# System clipboard # System clipboard
arboard = "3" arboard = "3"
# ── Temporary: platform-native custom MIME type clipboard ─────────────────────
# These deps exist because arboard does not yet support custom MIME types.
# Remove once arboard gains that feature (https://github.com/1Password/arboard/issues/14).
[target.'cfg(target_os = "linux")'.dependencies]
wl-clipboard-rs = "0.9"
x11-clipboard = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSData"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.60", features = [
"Win32_Foundation",
"Win32_System_DataExchange",
"Win32_System_Memory",
] }
[dev-dependencies] [dev-dependencies]
tiny-skia = "0.11" tiny-skia = "0.11"

View File

@ -1,16 +1,45 @@
//! Clipboard management for cut/copy/paste operations //! Clipboard management for cut/copy/paste operations.
//! //!
//! Supports multiple content types (clip instances, shapes) with //! # Content types
//! cross-platform clipboard integration via arboard. //! [`ClipboardContent`] covers every selectable item in the app:
//! - [`ClipInstances`](ClipboardContent::ClipInstances) — timeline clips
//! - [`VectorGeometry`](ClipboardContent::VectorGeometry) — DCEL shapes (stub; Phase 2)
//! - [`MidiNotes`](ClipboardContent::MidiNotes) — piano-roll notes
//! - [`RasterPixels`](ClipboardContent::RasterPixels) — raster selection
//! - [`Layers`](ClipboardContent::Layers) — complete layer subtrees
//! - [`AudioNodes`](ClipboardContent::AudioNodes) — audio-graph node subgraph
//!
//! # Storage strategy
//! Content is kept in three places simultaneously:
//! 1. **Internal** (`self.internal`) — in-process, zero-copy, always preferred.
//! 2. **Platform custom type** (`application/x-lightningbeam`) via
//! [`crate::clipboard_platform`] — enables cross-process paste between LB windows.
//! 3. **arboard text fallback** — `LIGHTNINGBEAM_CLIPBOARD:<json>` in the system
//! text clipboard for maximum compatibility (e.g. terminals, remote desktops).
//!
//! For `RasterPixels` an additional `image/png` entry is set on macOS and Windows
//! so the image can be pasted into external apps.
//!
//! # Temporary note
//! The custom-MIME platform layer ([`crate::clipboard_platform`]) is a shim until
//! arboard supports custom MIME types natively
//! (<https://github.com/1Password/arboard/issues/14>). When that lands, remove
//! `clipboard_platform`, the `objc2*` and `windows-sys` Cargo deps, and call
//! arboard directly.
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip}; use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
use crate::layer::{AudioLayerType, AnyLayer}; use crate::layer::{AudioLayerType, AnyLayer};
use crate::shape::Shape; use crate::clipboard_platform;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
/// Layer type tag for clipboard, so paste knows where clips can go /// MIME type used for cross-process Lightningbeam clipboard data.
pub const LIGHTNINGBEAM_MIME: &str = "application/x-lightningbeam";
// ─────────────────────────────── Layer type tag ─────────────────────────────
/// Layer type tag for clipboard — tells paste where clip instances can go.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum ClipboardLayerType { pub enum ClipboardLayerType {
Vector, Vector,
@ -21,7 +50,7 @@ pub enum ClipboardLayerType {
} }
impl ClipboardLayerType { impl ClipboardLayerType {
/// Determine the clipboard layer type from a document layer /// Determine the clipboard layer type from a document layer.
pub fn from_layer(layer: &AnyLayer) -> Self { pub fn from_layer(layer: &AnyLayer) -> Self {
match layer { match layer {
AnyLayer::Vector(_) => ClipboardLayerType::Vector, AnyLayer::Vector(_) => ClipboardLayerType::Vector,
@ -31,12 +60,12 @@ impl ClipboardLayerType {
AudioLayerType::Midi => ClipboardLayerType::AudioMidi, AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
}, },
AnyLayer::Effect(_) => ClipboardLayerType::Effect, AnyLayer::Effect(_) => ClipboardLayerType::Effect,
AnyLayer::Group(_) => ClipboardLayerType::Vector, // Groups don't have a direct clipboard type; treat as vector AnyLayer::Group(_) => ClipboardLayerType::Vector,
AnyLayer::Raster(_) => ClipboardLayerType::Vector, // Raster layers treated as vector for clipboard purposes AnyLayer::Raster(_) => ClipboardLayerType::Vector,
} }
} }
/// Check if a layer is compatible with this clipboard layer type /// Check if a layer is compatible with this clipboard layer type.
pub fn is_compatible(&self, layer: &AnyLayer) -> bool { pub fn is_compatible(&self, layer: &AnyLayer) -> bool {
match (self, layer) { match (self, layer) {
(ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true, (ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true,
@ -53,52 +82,122 @@ impl ClipboardLayerType {
} }
} }
/// Content stored in the clipboard // ──────────────────────────── Shared clip bundle ─────────────────────────────
/// Clip definitions referenced by clipboard content.
///
/// Shared between [`ClipboardContent::ClipInstances`] and [`ClipboardContent::Layers`].
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct ReferencedClips {
pub audio_clips: Vec<(Uuid, AudioClip)>,
pub video_clips: Vec<(Uuid, VideoClip)>,
pub vector_clips: Vec<(Uuid, VectorClip)>,
pub image_assets: Vec<(Uuid, ImageAsset)>,
}
// ───────────────────────────── Clipboard content ─────────────────────────────
/// Content stored in the clipboard.
///
/// The `serde(tag = "type")` discriminant is stable — unknown variants
/// deserialize as `None`, so new variants can be added without breaking
/// existing serialized data.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ClipboardContent { pub enum ClipboardContent {
/// Clip instances with their referenced clip definitions /// Timeline clip instances with all referenced clip definitions.
ClipInstances { ClipInstances {
/// Source layer type (for paste compatibility) /// Source layer type (for paste compatibility).
layer_type: ClipboardLayerType, layer_type: ClipboardLayerType,
/// The clip instances (IDs will be regenerated on paste) /// The clip instances (IDs regenerated on paste).
instances: Vec<ClipInstance>, instances: Vec<ClipInstance>,
/// Referenced audio clip definitions /// Referenced audio clip definitions.
audio_clips: Vec<(Uuid, AudioClip)>, audio_clips: Vec<(Uuid, AudioClip)>,
/// Referenced video clip definitions /// Referenced video clip definitions.
video_clips: Vec<(Uuid, VideoClip)>, video_clips: Vec<(Uuid, VideoClip)>,
/// Referenced vector clip definitions /// Referenced vector clip definitions.
vector_clips: Vec<(Uuid, VectorClip)>, vector_clips: Vec<(Uuid, VectorClip)>,
/// Referenced image assets /// Referenced image assets.
image_assets: Vec<(Uuid, ImageAsset)>, image_assets: Vec<(Uuid, ImageAsset)>,
}, },
/// Shapes from a vector layer's keyframe
Shapes { /// Selected DCEL geometry from a vector layer.
/// Shapes (with embedded transforms) ///
shapes: Vec<Shape>, /// Currently a stub — `data` is opaque bytes whose format is TBD in Phase 2
/// once DCEL serialization is implemented. Copy/paste of vector shapes does
/// nothing until then. Secondary formats (`image/svg+xml`, `image/png`) are
/// also deferred to Phase 2.
VectorGeometry {
/// Opaque DCEL subgraph bytes (format TBD, Phase 2).
data: Vec<u8>,
}, },
/// MIDI notes from the piano roll
/// MIDI notes from the piano roll.
MidiNotes { MidiNotes {
/// Notes: (start_time, note, velocity, duration) — times relative to selection start /// `(start_time, note, velocity, duration)` — times relative to selection start.
notes: Vec<(f64, u8, u8, f64)>, notes: Vec<(f64, u8, u8, f64)>,
}, },
/// Raw pixel data from a raster layer selection.
/// Raw pixel region from a raster layer selection.
///
/// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes — /// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes —
/// the same in-memory format as `RasterKeyframe::raw_pixels`. /// the same in-memory format as `RasterKeyframe::raw_pixels`.
///
/// On macOS and Windows an `image/png` secondary format is also set so the
/// content can be pasted into external apps.
RasterPixels { RasterPixels {
pixels: Vec<u8>, pixels: Vec<u8>,
width: u32, width: u32,
height: u32, height: u32,
}, },
/// One or more complete layers copied from the timeline.
///
/// [`AnyLayer`] derives `Serialize`/`Deserialize`; only
/// `RasterKeyframe::raw_pixels` is excluded from serde (`#[serde(skip)]`) and
/// is therefore carried separately in `raster_pixels`.
///
/// On paste: insert as sibling layers at the current selection point with all
/// UUIDs regenerated.
Layers {
/// Complete serialized layer trees (raw_pixels absent).
layers: Vec<AnyLayer>,
/// Raster pixel data keyed by `(layer_id, time.to_bits())`.
/// Restored into `RasterKeyframe::raw_pixels` after deserialization by
/// matching layer_id + time_bits against the deserialized keyframes.
raster_pixels: Vec<(Uuid, u64, Vec<u8>)>,
/// All clip definitions referenced by any of the copied layers.
referenced_clips: ReferencedClips,
},
/// Selected nodes and edges from an audio effect/synthesis graph.
///
/// Uses the same serialization types as preset save/load
/// (`daw_backend::audio::node_graph::preset`).
///
/// On paste: add nodes to the target layer's graph with new IDs, then sync
/// to the DAW backend (same pattern as `ClipInstances`).
AudioNodes {
/// Selected nodes.
nodes: Vec<daw_backend::SerializedNode>,
/// Connections between the selected nodes only.
connections: Vec<daw_backend::SerializedConnection>,
/// Source layer UUID — hint for paste target validation.
source_layer_id: Uuid,
},
} }
// ──────────────────────── ID regeneration ───────────────────────────────────
impl ClipboardContent { impl ClipboardContent {
/// Create a clone of this content with all UUIDs regenerated /// Clone this content with all UUIDs regenerated.
/// Returns the new content and a mapping from old to new IDs ///
/// Returns the new content and a mapping from old → new IDs.
pub fn with_regenerated_ids(&self) -> (Self, HashMap<Uuid, Uuid>) { pub fn with_regenerated_ids(&self) -> (Self, HashMap<Uuid, Uuid>) {
let mut id_map = HashMap::new(); let mut id_map: HashMap<Uuid, Uuid> = HashMap::new();
match self { match self {
// ── ClipInstances ───────────────────────────────────────────────
ClipboardContent::ClipInstances { ClipboardContent::ClipInstances {
layer_type, layer_type,
instances, instances,
@ -107,67 +206,11 @@ impl ClipboardContent {
vector_clips, vector_clips,
image_assets, image_assets,
} => { } => {
// Regenerate clip definition IDs let new_audio_clips = regen_audio_clips(audio_clips, &mut id_map);
let new_audio_clips: Vec<(Uuid, AudioClip)> = audio_clips let new_video_clips = regen_video_clips(video_clips, &mut id_map);
.iter() let new_vector_clips = regen_vector_clips(vector_clips, &mut id_map);
.map(|(old_id, clip)| { let new_image_assets = regen_image_assets(image_assets, &mut id_map);
let new_id = Uuid::new_v4(); let new_instances = regen_clip_instances(instances, &mut id_map);
id_map.insert(*old_id, new_id);
let mut new_clip = clip.clone();
new_clip.id = new_id;
(new_id, new_clip)
})
.collect();
let new_video_clips: Vec<(Uuid, VideoClip)> = video_clips
.iter()
.map(|(old_id, clip)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut new_clip = clip.clone();
new_clip.id = new_id;
(new_id, new_clip)
})
.collect();
let new_vector_clips: Vec<(Uuid, VectorClip)> = vector_clips
.iter()
.map(|(old_id, clip)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut new_clip = clip.clone();
new_clip.id = new_id;
(new_id, new_clip)
})
.collect();
let new_image_assets: Vec<(Uuid, ImageAsset)> = image_assets
.iter()
.map(|(old_id, asset)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut new_asset = asset.clone();
new_asset.id = new_id;
(new_id, new_asset)
})
.collect();
// Regenerate clip instance IDs and remap clip_id references
let new_instances: Vec<ClipInstance> = instances
.iter()
.map(|inst| {
let new_instance_id = Uuid::new_v4();
id_map.insert(inst.id, new_instance_id);
let mut new_inst = inst.clone();
new_inst.id = new_instance_id;
// Remap clip_id to new definition ID
if let Some(new_clip_id) = id_map.get(&inst.clip_id) {
new_inst.clip_id = *new_clip_id;
}
new_inst
})
.collect();
( (
ClipboardContent::ClipInstances { ClipboardContent::ClipInstances {
layer_type: layer_type.clone(), layer_type: layer_type.clone(),
@ -180,29 +223,82 @@ impl ClipboardContent {
id_map, id_map,
) )
} }
// ── VectorGeometry ──────────────────────────────────────────────
// TODO (Phase 2): remap DCEL vertex/edge UUIDs once DCEL serialization
// is defined.
ClipboardContent::VectorGeometry { data } => {
(ClipboardContent::VectorGeometry { data: data.clone() }, id_map)
}
// ── MidiNotes ───────────────────────────────────────────────────
ClipboardContent::MidiNotes { notes } => { ClipboardContent::MidiNotes { notes } => {
// No IDs to regenerate, just clone
(ClipboardContent::MidiNotes { notes: notes.clone() }, id_map) (ClipboardContent::MidiNotes { notes: notes.clone() }, id_map)
} }
ClipboardContent::RasterPixels { pixels, width, height } => {
(ClipboardContent::RasterPixels { pixels: pixels.clone(), width: *width, height: *height }, id_map) // ── RasterPixels ────────────────────────────────────────────────
} ClipboardContent::RasterPixels { pixels, width, height } => (
ClipboardContent::Shapes { shapes } => { ClipboardContent::RasterPixels {
// Regenerate shape IDs pixels: pixels.clone(),
let new_shapes: Vec<Shape> = shapes width: *width,
height: *height,
},
id_map,
),
// ── Layers ──────────────────────────────────────────────────────
ClipboardContent::Layers { layers, raster_pixels, referenced_clips } => {
let new_clips = regen_referenced_clips(referenced_clips, &mut id_map);
let new_layers: Vec<AnyLayer> = layers
.iter() .iter()
.map(|shape| { .map(|l| regen_any_layer(l, &mut id_map))
let new_id = Uuid::new_v4(); .collect();
id_map.insert(shape.id, new_id); // Remap raster_pixels layer_id keys.
let mut new_shape = shape.clone(); let new_raster: Vec<(Uuid, u64, Vec<u8>)> = raster_pixels
new_shape.id = new_id; .iter()
new_shape .map(|(old_lid, time_bits, px)| {
let new_lid = id_map.get(old_lid).copied().unwrap_or(*old_lid);
(new_lid, *time_bits, px.clone())
}) })
.collect(); .collect();
( (
ClipboardContent::Shapes { ClipboardContent::Layers {
shapes: new_shapes, layers: new_layers,
raster_pixels: new_raster,
referenced_clips: new_clips,
},
id_map,
)
}
// ── AudioNodes ──────────────────────────────────────────────────
ClipboardContent::AudioNodes { nodes, connections, source_layer_id } => {
// Remap u32 node IDs.
let mut node_id_map: HashMap<u32, u32> = HashMap::new();
let new_nodes: Vec<daw_backend::SerializedNode> = nodes
.iter()
.map(|n| {
let new_id = node_id_map.len() as u32 + 1;
node_id_map.insert(n.id, new_id);
let mut nn = n.clone();
nn.id = new_id;
nn
})
.collect();
let new_connections: Vec<daw_backend::SerializedConnection> = connections
.iter()
.map(|c| {
let mut nc = c.clone();
nc.from_node = node_id_map.get(&c.from_node).copied().unwrap_or(c.from_node);
nc.to_node = node_id_map.get(&c.to_node).copied().unwrap_or(c.to_node);
nc
})
.collect();
(
ClipboardContent::AudioNodes {
nodes: new_nodes,
connections: new_connections,
source_layer_id: *source_layer_id,
}, },
id_map, id_map,
) )
@ -211,72 +307,184 @@ impl ClipboardContent {
} }
} }
/// JSON prefix for clipboard text to identify Lightningbeam content // ──────────────────────── ID regeneration helpers ───────────────────────────
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
/// Manages clipboard operations with internal + system clipboard fn regen_audio_clips(
pub struct ClipboardManager { clips: &[(Uuid, AudioClip)],
/// Internal clipboard (preserves rich data without serialization loss) id_map: &mut HashMap<Uuid, Uuid>,
internal: Option<ClipboardContent>, ) -> Vec<(Uuid, AudioClip)> {
/// System clipboard handle (lazy-initialized) clips
system: Option<arboard::Clipboard>, .iter()
.map(|(old_id, clip)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut c = clip.clone();
c.id = new_id;
(new_id, c)
})
.collect()
} }
impl ClipboardManager { fn regen_video_clips(
/// Create a new clipboard manager clips: &[(Uuid, crate::clip::VideoClip)],
pub fn new() -> Self { id_map: &mut HashMap<Uuid, Uuid>,
let system = arboard::Clipboard::new().ok(); ) -> Vec<(Uuid, crate::clip::VideoClip)> {
Self { clips
internal: None, .iter()
system, .map(|(old_id, clip)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut c = clip.clone();
c.id = new_id;
(new_id, c)
})
.collect()
}
fn regen_vector_clips(
clips: &[(Uuid, VectorClip)],
id_map: &mut HashMap<Uuid, Uuid>,
) -> Vec<(Uuid, VectorClip)> {
clips
.iter()
.map(|(old_id, clip)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut c = clip.clone();
c.id = new_id;
(new_id, c)
})
.collect()
}
fn regen_image_assets(
assets: &[(Uuid, ImageAsset)],
id_map: &mut HashMap<Uuid, Uuid>,
) -> Vec<(Uuid, ImageAsset)> {
assets
.iter()
.map(|(old_id, asset)| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
let mut a = asset.clone();
a.id = new_id;
(new_id, a)
})
.collect()
}
fn regen_clip_instances(
instances: &[ClipInstance],
id_map: &mut HashMap<Uuid, Uuid>,
) -> Vec<ClipInstance> {
instances
.iter()
.map(|inst| {
let new_id = Uuid::new_v4();
id_map.insert(inst.id, new_id);
let mut i = inst.clone();
i.id = new_id;
if let Some(new_clip_id) = id_map.get(&inst.clip_id) {
i.clip_id = *new_clip_id;
}
i
})
.collect()
}
fn regen_referenced_clips(
rc: &ReferencedClips,
id_map: &mut HashMap<Uuid, Uuid>,
) -> ReferencedClips {
ReferencedClips {
audio_clips: regen_audio_clips(&rc.audio_clips, id_map),
video_clips: regen_video_clips(&rc.video_clips, id_map),
vector_clips: regen_vector_clips(&rc.vector_clips, id_map),
image_assets: regen_image_assets(&rc.image_assets, id_map),
} }
} }
/// Copy content to both internal and system clipboard /// Regenerate the layer's own ID (and all descendant IDs for group layers).
pub fn copy(&mut self, content: ClipboardContent) { fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap<Uuid, Uuid>) -> AnyLayer {
// Serialize to system clipboard as JSON text match layer {
if let Some(system) = self.system.as_mut() { AnyLayer::Vector(vl) => {
if let Ok(json) = serde_json::to_string(&content) { let new_layer_id = Uuid::new_v4();
let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json); id_map.insert(vl.layer.id, new_layer_id);
let _ = system.set_text(clipboard_text); let mut nl = vl.clone();
nl.layer.id = new_layer_id;
nl.clip_instances = regen_clip_instances(&vl.clip_instances, id_map);
AnyLayer::Vector(nl)
} }
AnyLayer::Audio(al) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(al.layer.id, new_layer_id);
let mut nl = al.clone();
nl.layer.id = new_layer_id;
nl.clip_instances = regen_clip_instances(&al.clip_instances, id_map);
AnyLayer::Audio(nl)
} }
AnyLayer::Video(vl) => {
// Store internally for rich access let new_layer_id = Uuid::new_v4();
self.internal = Some(content); id_map.insert(vl.layer.id, new_layer_id);
let mut nl = vl.clone();
nl.layer.id = new_layer_id;
nl.clip_instances = regen_clip_instances(&vl.clip_instances, id_map);
AnyLayer::Video(nl)
} }
AnyLayer::Effect(el) => {
/// Try to paste content let new_layer_id = Uuid::new_v4();
/// Returns internal clipboard if available, falls back to system clipboard JSON id_map.insert(el.layer.id, new_layer_id);
pub fn paste(&mut self) -> Option<ClipboardContent> { let mut nl = el.clone();
// Try internal clipboard first nl.layer.id = new_layer_id;
if let Some(content) = &self.internal { nl.clip_instances = regen_clip_instances(&el.clip_instances, id_map);
return Some(content.clone()); AnyLayer::Effect(nl)
} }
AnyLayer::Raster(rl) => {
// Fall back to system clipboard let new_layer_id = Uuid::new_v4();
if let Some(system) = self.system.as_mut() { id_map.insert(rl.layer.id, new_layer_id);
if let Ok(text) = system.get_text() { let mut nl = rl.clone();
if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) { nl.layer.id = new_layer_id;
if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) { AnyLayer::Raster(nl)
return Some(content);
} }
AnyLayer::Group(gl) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(gl.layer.id, new_layer_id);
let mut nl = gl.clone();
nl.layer.id = new_layer_id;
nl.children = gl.children.iter().map(|c| regen_any_layer(c, id_map)).collect();
AnyLayer::Group(nl)
} }
} }
} }
None // ──────────────────────── Pixel format conversion helpers ────────────────────
/// Convert straight-alpha RGBA bytes to premultiplied RGBA.
fn straight_to_premul(bytes: &[u8]) -> Vec<u8> {
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()
} }
/// Copy raster pixels to the system clipboard as an image. /// Convert premultiplied RGBA bytes to straight-alpha RGBA.
/// fn premul_to_straight(bytes: &[u8]) -> Vec<u8> {
/// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes). bytes
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors .chunks_exact(4)
/// (arboard is a temporary integration point and will be replaced). .flat_map(|p| {
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]; let a = p[3];
if a == 0 { if a == 0 {
[0u8, 0, 0, 0] [0u8, 0, 0, 0]
@ -289,7 +497,103 @@ impl ClipboardManager {
a, a,
] ]
} }
}).collect(); })
.collect()
}
// ──────────────────────────── PNG encoding helper ────────────────────────────
/// Encode sRGB premultiplied RGBA pixels as PNG bytes.
///
/// Returns `None` on encoding failure (logged to stderr).
pub(crate) fn encode_raster_as_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
use image::RgbaImage;
let img = RgbaImage::from_raw(width, height, premul_to_straight(pixels))?;
match crate::brush_engine::encode_png(&img) {
Ok(bytes) => Some(bytes),
Err(e) => {
eprintln!("clipboard: PNG encode failed: {e}");
None
}
}
}
// ───────────────────────────── ClipboardManager ─────────────────────────────
/// Manages clipboard operations with internal + system clipboard.
pub struct ClipboardManager {
/// Internal clipboard (preserves rich data without serialization loss).
internal: Option<ClipboardContent>,
/// System clipboard handle (lazy-initialized).
system: Option<arboard::Clipboard>,
}
impl ClipboardManager {
/// Create a new clipboard manager.
pub fn new() -> Self {
let system = arboard::Clipboard::new().ok();
Self { internal: None, system }
}
/// Copy content to the internal clipboard, the platform custom-MIME clipboard,
/// and the arboard text-fallback clipboard.
pub fn copy(&mut self, content: ClipboardContent) {
let json = serde_json::to_string(&content).unwrap_or_default();
// Build platform entries (custom MIME always present; PNG secondary for raster).
let mut entries: Vec<(&str, Vec<u8>)> =
vec![(LIGHTNINGBEAM_MIME, json.as_bytes().to_vec())];
if let ClipboardContent::RasterPixels { pixels, width, height } = &content {
if let Some(png) = encode_raster_as_png(pixels, *width, *height) {
entries.push(("image/png", png));
}
}
clipboard_platform::set(
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
);
self.internal = Some(content);
}
/// Try to paste content.
///
/// Checks the platform custom MIME type first. If our content is still on
/// the clipboard the internal cache is returned (avoids re-deserializing).
/// If another app has taken the clipboard since we last copied, the internal
/// cache is cleared and `None` is returned so the caller can try other
/// sources (e.g. `try_get_raster_image`).
pub fn paste(&mut self) -> Option<ClipboardContent> {
match clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) {
Some((_, data)) => {
// Our MIME type is still on the clipboard — prefer the internal
// cache to avoid a round-trip through JSON.
if let Some(content) = &self.internal {
return Some(content.clone());
}
// Cross-process paste (internal cache absent): deserialize.
if let Ok(s) = std::str::from_utf8(&data) {
if let Ok(content) = serde_json::from_str::<ClipboardContent>(s) {
return Some(content);
}
}
None
}
None => {
// Another app owns the clipboard — internal cache is stale.
self.internal = None;
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.
pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) {
let Some(system) = self.system.as_mut() else { return };
let straight = premul_to_straight(pixels);
let img = arboard::ImageData { let img = arboard::ImageData {
width: width as usize, width: width as usize,
height: height as usize, height: height as usize,
@ -303,39 +607,34 @@ impl ClipboardManager {
/// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if /// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if
/// no image is available. Silently ignores errors. /// no image is available. Silently ignores errors.
pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> { pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> {
// On Linux arboard's get_image() does not reliably read clipboard images
// set by other apps on Wayland. Use clipboard_platform (wl-clipboard-rs /
// x11-clipboard) to read the raw image bytes then decode with the image crate.
#[cfg(target_os = "linux")]
{
let (_, data) = clipboard_platform::get(&[
"image/png",
"image/jpeg",
"image/bmp",
"image/tiff",
])?;
let img = image::load_from_memory(&data).ok()?.into_rgba8();
let (width, height) = img.dimensions();
let premul = straight_to_premul(img.as_raw());
return Some((premul, width, height));
}
// macOS / Windows: arboard handles image clipboard natively.
#[cfg(not(target_os = "linux"))]
{
let img = self.system.as_mut()?.get_image().ok()?; let img = self.system.as_mut()?.get_image().ok()?;
let width = img.width as u32; let premul = straight_to_premul(&img.bytes);
let height = img.height as u32; Some((premul, img.width as u32, 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() {
return true;
}
if let Some(system) = self.system.as_mut() {
if let Ok(text) = system.get_text() {
return text.starts_with(CLIPBOARD_PREFIX);
} }
} }
false /// Check if there is content available to paste.
pub fn has_content(&self) -> bool {
self.internal.is_some()
} }
} }

View File

@ -0,0 +1,310 @@
//! Platform-native clipboard integration for custom MIME types.
//!
//! > **Temporary shim** — this module exists because arboard does not yet support
//! > custom MIME types. Once arboard gains that capability (tracked at
//! > <https://github.com/1Password/arboard/issues/14>) this module and all
//! > platform-conditional deps (`objc2*`, `windows-sys`, `wl-clipboard-rs`,
//! > `x11-clipboard`) should be removed and replaced with a single arboard call.
//!
//! Provides [`set`] and [`get`] functions for reading and writing non-text
//! clipboard formats directly via each platform's native clipboard API.
//!
//! # Platform notes
//! - **macOS**: NSPasteboard via objc2 — appends entries to the clipboard that
//! arboard already opened; must be called *after* `arboard::set_text()` /
//! `set_image()` since arboard calls `clearContents` internally.
//! - **Windows**: `RegisterClipboardFormat` + `SetClipboardData` — appends to
//! the clipboard arboard already populated; must be called *after* arboard.
//! - **Linux/Wayland**: `wl-clipboard-rs` — creates its own Wayland connection
//! and spawns a background thread to serve clipboard requests; no external
//! tools required.
//! - **Linux/X11**: `x11-clipboard` — serves custom-atom requests via its
//! background thread; only the first entry is set (X11 single-target
//! limitation per selection).
/// Set one or more `(mime_type, data)` pairs on the platform clipboard.
///
/// On macOS and Windows this must be called **after** `arboard::Clipboard::set_text()` /
/// `set_image()` because arboard empties the clipboard first.
///
/// On Linux/X11 only the first entry is used.
pub fn set(entries: &[(&str, &[u8])]) {
platform_impl::set(entries);
}
/// Return the first available `(mime_type, data)` pair from the platform clipboard.
///
/// `preferred` is tried in order; the first MIME type with data wins.
/// Returns `None` when none of the requested types are present.
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
platform_impl::get(preferred)
}
// ─────────────────────────────────── macOS ──────────────────────────────────
#[cfg(target_os = "macos")]
mod platform_impl {
use objc2::rc::Retained;
use objc2_app_kit::NSPasteboard;
use objc2_foundation::{NSData, NSString};
pub fn set(entries: &[(&str, &[u8])]) {
// SAFETY: must be called from the main thread (same as ClipboardManager).
unsafe {
let pb = NSPasteboard::generalPasteboard();
for &(mime, data) in entries {
let ns_type: Retained<NSString> = NSString::from_str(mime);
let ns_data: Retained<NSData> = NSData::with_bytes(data);
// setData:forType: appends to the current clipboard contents
// (arboard already called clearContents, so no double-clear needed).
pb.setData_forType(&ns_data, &ns_type);
}
}
}
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
// SAFETY: must be called from the main thread.
unsafe {
let pb = NSPasteboard::generalPasteboard();
for &mime in preferred {
let ns_type: Retained<NSString> = NSString::from_str(mime);
if let Some(ns_data) = pb.dataForType(&ns_type) {
// NSData implements AsRef<[u8]> in objc2-foundation.
let bytes = ns_data.as_ref().to_vec();
return Some((mime.to_string(), bytes));
}
}
}
None
}
}
// ─────────────────────────────────── Windows ────────────────────────────────
#[cfg(target_os = "windows")]
mod platform_impl {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, GetClipboardData, OpenClipboard, RegisterClipboardFormatW, SetClipboardData,
};
use windows_sys::Win32::System::Memory::{
GlobalAlloc, GlobalFree, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE,
};
static FORMAT_IDS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
/// Register (or look up) the clipboard format ID for a MIME-type string.
fn registered_format(mime: &str) -> u32 {
let ids = FORMAT_IDS.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = ids.lock().unwrap_or_else(|e| e.into_inner());
if let Some(&id) = guard.get(mime) {
return id;
}
// RegisterClipboardFormatW requires a null-terminated UTF-16 string.
let wide: Vec<u16> = mime.encode_utf16().chain(std::iter::once(0)).collect();
let id = unsafe { RegisterClipboardFormatW(wide.as_ptr()) };
guard.insert(mime.to_string(), id);
id
}
pub fn set(entries: &[(&str, &[u8])]) {
// arboard already called EmptyClipboard; we just append new formats.
// OpenClipboard(NULL) acquires ownership without clearing.
unsafe {
if OpenClipboard(std::ptr::null_mut()) == 0 {
return;
}
for &(mime, data) in entries {
let fmt = registered_format(mime);
let h = GlobalAlloc(GMEM_MOVEABLE, data.len());
if h.is_null() {
continue;
}
let ptr = GlobalLock(h);
if ptr.is_null() {
GlobalFree(h);
continue;
}
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as *mut u8, data.len());
GlobalUnlock(h);
SetClipboardData(fmt, h as HANDLE);
}
CloseClipboard();
}
}
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
unsafe {
if OpenClipboard(std::ptr::null_mut()) == 0 {
return None;
}
let mut result = None;
for &mime in preferred {
let fmt = registered_format(mime);
let h = GetClipboardData(fmt);
if h.is_null() {
continue;
}
let ptr = GlobalLock(h as _);
if ptr.is_null() {
continue;
}
let size = GlobalSize(h as _);
let data = std::slice::from_raw_parts(ptr as *const u8, size).to_vec();
GlobalUnlock(h as _);
result = Some((mime.to_string(), data));
break;
}
CloseClipboard();
result
}
}
}
// ─────────────────────────────────── Linux ──────────────────────────────────
#[cfg(target_os = "linux")]
mod platform_impl {
pub fn set(entries: &[(&str, &[u8])]) {
if std::env::var("WAYLAND_DISPLAY").is_ok() {
set_wayland(entries);
} else {
set_x11(entries);
}
}
pub fn get(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
if std::env::var("WAYLAND_DISPLAY").is_ok() {
get_wayland(preferred)
} else {
get_x11(preferred)
}
}
// ── Wayland ──────────────────────────────────────────────────────────────
fn set_wayland(entries: &[(&str, &[u8])]) {
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
let sources: Vec<MimeSource> = entries
.iter()
.map(|&(mime, data)| MimeSource {
source: Source::Bytes(data.to_vec().into_boxed_slice()),
mime_type: MimeType::Specific(mime.to_string()),
})
.collect();
// copy_multi spawns a background thread that serves clipboard requests
// until another client takes ownership — no blocking, no subprocess needed.
if let Err(e) = Options::new().copy_multi(sources) {
eprintln!("[clipboard_platform] wl-clipboard-rs set error: {e}");
}
}
fn get_wayland(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
use std::io::Read;
use wl_clipboard_rs::paste::{self, ClipboardType, Error, MimeType, Seat};
for &mime in preferred {
match paste::get_contents(
ClipboardType::Regular,
Seat::Unspecified,
MimeType::Specific(mime),
) {
Ok((mut pipe, _)) => {
let mut buf = Vec::new();
if pipe.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
return Some((mime.to_string(), buf));
}
}
// These are non-error "not present" conditions — try the next type.
Err(Error::ClipboardEmpty) | Err(Error::NoMimeType) => continue,
Err(e) => {
eprintln!("[clipboard_platform] wl-clipboard-rs get error for {mime}: {e}");
continue;
}
}
}
None
}
// ── X11 ──────────────────────────────────────────────────────────────────
use std::sync::Mutex;
use std::time::Duration;
/// Keeps the x11-clipboard instance alive so its background thread can
/// continue serving SelectionRequest events. Replaced on each `set_x11` call.
static X11_CB: Mutex<Option<x11_clipboard::Clipboard>> = Mutex::new(None);
fn set_x11(entries: &[(&str, &[u8])]) {
// X11 clipboard can only serve one target atom per selection owner.
// Use the first entry (the custom LB MIME type).
let Some(&(mime, data)) = entries.first() else { return };
let cb = match x11_clipboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
eprintln!("[clipboard_platform] x11-clipboard init error: {e}");
return;
}
};
let atom = match cb.setter.get_atom(mime) {
Ok(a) => a,
Err(e) => {
eprintln!("[clipboard_platform] x11-clipboard intern atom error for {mime}: {e}");
return;
}
};
if let Err(e) = cb.store(cb.setter.atoms.clipboard, atom, data.to_vec()) {
eprintln!("[clipboard_platform] x11-clipboard store error: {e}");
return;
}
// Keep alive to serve requests; replacing drops the previous instance.
*X11_CB.lock().unwrap_or_else(|e| e.into_inner()) = Some(cb);
}
fn get_x11(preferred: &[&str]) -> Option<(String, Vec<u8>)> {
let cb = match x11_clipboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
eprintln!("[clipboard_platform] x11-clipboard init error: {e}");
return None;
}
};
for &mime in preferred {
let atom = match cb.getter.get_atom(mime) {
Ok(a) => a,
Err(_) => continue,
};
match cb.load(
cb.getter.atoms.clipboard,
atom,
cb.getter.atoms.property,
Duration::from_secs(1),
) {
Ok(data) if !data.is_empty() => return Some((mime.to_string(), data)),
_ => continue,
}
}
None
}
}
// ──────────────────────────── Fallback (other OS) ───────────────────────────
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
mod platform_impl {
pub fn set(_entries: &[(&str, &[u8])]) {}
pub fn get(_preferred: &[&str]) -> Option<(String, Vec<u8>)> {
None
}
}

View File

@ -43,6 +43,7 @@ pub mod file_types;
pub mod file_io; pub mod file_io;
pub mod export; pub mod export;
pub mod clipboard; pub mod clipboard;
pub(crate) mod clipboard_platform;
pub mod region_select; pub mod region_select;
pub mod dcel2; pub mod dcel2;
pub use dcel2 as dcel; pub use dcel2 as dcel;

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

@ -1417,7 +1417,8 @@ impl EditorApp {
5 => { 5 => {
// Painting focus -> RasterLayer // Painting focus -> RasterLayer
use lightningbeam_core::raster_layer::RasterLayer; use lightningbeam_core::raster_layer::RasterLayer;
let layer = RasterLayer::new("Raster 1"); let mut layer = RasterLayer::new("Raster 1");
layer.ensure_keyframe_at(self.playback_time, document.width as u32, document.height as u32);
document.root.add_child(AnyLayer::Raster(layer)) document.root.add_child(AnyLayer::Raster(layer))
} }
_ => { _ => {
@ -1924,6 +1925,11 @@ impl EditorApp {
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; 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 }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
// Ensure the canvas is allocated (empty Vec = blank transparent canvas).
let expected = (kf.width * kf.height * 4) as usize;
if kf.raw_pixels.len() != expected {
kf.raw_pixels.resize(expected, 0);
}
Self::composite_over( Self::composite_over(
&mut kf.raw_pixels, kf.width, kf.height, &mut kf.raw_pixels, kf.width, kf.height,
&float.pixels, float.width, float.height, &float.pixels, float.width, float.height,
@ -1957,30 +1963,64 @@ 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 matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
if let Some(float) = &self.selection.raster_floating {
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
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(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) {
if let Some(kf) = rl.keyframe_at(self.playback_time) { if let Some(kf) = rl.keyframe_at(self.playback_time) {
let (pixels, w, h) = Self::extract_raster_selection( let (pixels, w, h) = Self::extract_raster_selection(
&kf.raw_pixels, kf.width, kf.height, raster_sel, &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 { self.clipboard_manager.copy(ClipboardContent::RasterPixels {
pixels, width: w, height: h, pixels, width: w, height: h,
}); });
} }
}
return; return;
} }
} }
}
// Check what's selected: clip instances take priority, then shapes // Check what's selected: clip instances take priority, then shapes
if !self.selection.clip_instances().is_empty() { if !self.selection.clip_instances().is_empty() {
@ -2057,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();
@ -2294,37 +2343,31 @@ impl EditorApp {
self.selection.add_clip_instance(id); self.selection.add_clip_instance(id);
} }
} }
ClipboardContent::Shapes { shapes } => { ClipboardContent::VectorGeometry { .. } => {
let active_layer_id = match self.active_layer_id { // TODO (Phase 2): paste DCEL subgraph once vector serialization is defined.
Some(id) => id,
None => return,
};
// Add shapes to the active vector layer's keyframe
let document = self.action_executor.document_mut();
let layer = match document.get_layer_mut(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => {
eprintln!("Cannot paste shapes: not a vector layer");
return;
} }
}; ClipboardContent::Layers { .. } => {
// TODO: insert copied layers as siblings at the current selection point.
// TODO: DCEL - paste shapes not yet implemented }
let _ = (vector_layer, shapes); ClipboardContent::AudioNodes { .. } => {
// TODO: add nodes to the target layer's audio graph with new IDs and
// sync to the DAW backend.
} }
ClipboardContent::MidiNotes { .. } => { ClipboardContent::MidiNotes { .. } => {
// MIDI notes are pasted directly in the piano roll pane, not here // MIDI notes are pasted directly in the piano roll pane, not here
} }
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 Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) else { return }; let layer = document.get_layer(&layer_id);
let Some(AnyLayer::Raster(rl)) = layer else { return };
let Some(kf) = rl.keyframe_at(self.playback_time) else { return }; let Some(kf) = rl.keyframe_at(self.playback_time) else { return };
// Paste position: top-left of the current raster selection if any, // Paste position: top-left of the current raster selection if any,
@ -2334,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,
@ -2786,6 +2826,13 @@ impl EditorApp {
// Edit menu // Edit menu
MenuAction::Undo => { MenuAction::Undo => {
// An uncommitted floating selection (paste not yet merged) lives
// outside the action stack. Cancelling it IS the undo — dismiss
// it and don't pop anything from the stack.
if self.selection.raster_floating.is_some() {
self.cancel_raster_floating();
return;
}
let undo_succeeded = if let Some(ref controller_arc) = self.audio_controller { let undo_succeeded = if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
let mut backend_context = lightningbeam_core::action::BackendContext { let mut backend_context = lightningbeam_core::action::BackendContext {
@ -3099,7 +3146,11 @@ impl EditorApp {
let layer_number = context_layers.len() + 1; let layer_number = context_layers.len() + 1;
let layer_name = format!("Raster {}", layer_number); let layer_name = format!("Raster {}", layer_number);
let layer = RasterLayer::new(layer_name); let doc = self.action_executor.document();
let (doc_w, doc_h) = (doc.width as u32, doc.height as u32);
drop(doc);
let mut layer = RasterLayer::new(layer_name);
layer.ensure_keyframe_at(self.playback_time, doc_w, doc_h);
let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer)) let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer))
.with_target_clip(editing_clip_id); .with_target_clip(editing_clip_id);
let _ = self.action_executor.execute(Box::new(action)); let _ = self.action_executor.execute(Box::new(action));
@ -5818,17 +5869,32 @@ 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
// falls through to a Key event instead of Event::Paste.
egui::Event::Key {
key: egui::Key::V,
pressed: true,
modifiers,
..
} if modifiers.ctrl || modifiers.command => {
self.handle_menu_action(MenuAction::Paste);
clipboard_handled = true;
} }
_ => {} _ => {}
} }
@ -5837,12 +5903,17 @@ 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 {
if !(is_clipboard && clipboard_handled) {
self.handle_menu_action(action); self.handle_menu_action(action);
} }
} }
}
// Check tool shortcuts (only if no text input is focused; // Check tool shortcuts (only if no text input is focused;
// modifier guard is encoded in the bindings themselves — default tool bindings have no modifiers) // modifier guard is encoded in the bindings themselves — default tool bindings have no modifiers)

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);
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 { *shared.tool_state = ToolState::DrawingRasterMarquee {
start: (px, py), start: (px, py),
current: (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 {
ToolState::DrawingRasterMarquee { start, ref mut current } => {
*current = (px, py); *current = (px, py);
let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w)); 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)); 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)); 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 {
ToolState::DrawingRasterMarquee { start, current } => {
let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w)); 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)); 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 { if x1 > x0 && y1 > y0 {
Some(RasterSelection::Rect(x0, y0, x1, y1)) shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1));
Self::lift_selection_to_float(shared);
} else { } else {
None shared.selection.raster_selection = None;
};
*shared.tool_state = ToolState::Idle;
} }
} }
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).
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); Self::commit_raster_floating_now(shared);
shared.selection.raster_selection = None; 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,
); );