Don't dump json into text clipboard

This commit is contained in:
Skyler Lehmkuhl 2026-03-02 07:30:09 -05:00
parent a45d674ed7
commit 75e35b0ac6
6 changed files with 990 additions and 197 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,48 @@
//! 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";
/// JSON text-clipboard prefix (arboard fallback).
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
// ─────────────────────────────── 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 +53,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 +63,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 +85,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 +209,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 +226,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,52 +310,281 @@ 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(
clips: &[(Uuid, AudioClip)],
id_map: &mut HashMap<Uuid, Uuid>,
) -> Vec<(Uuid, AudioClip)> {
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_video_clips(
clips: &[(Uuid, crate::clip::VideoClip)],
id_map: &mut HashMap<Uuid, Uuid>,
) -> Vec<(Uuid, crate::clip::VideoClip)> {
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_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),
}
}
/// Regenerate the layer's own ID (and all descendant IDs for group layers).
fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap<Uuid, Uuid>) -> AnyLayer {
match layer {
AnyLayer::Vector(vl) => {
let new_layer_id = Uuid::new_v4();
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::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) => {
let new_layer_id = Uuid::new_v4();
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) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(el.layer.id, new_layer_id);
let mut nl = el.clone();
nl.layer.id = new_layer_id;
nl.clip_instances = regen_clip_instances(&el.clip_instances, id_map);
AnyLayer::Effect(nl)
}
AnyLayer::Raster(rl) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(rl.layer.id, new_layer_id);
let mut nl = rl.clone();
nl.layer.id = new_layer_id;
AnyLayer::Raster(nl)
}
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)
}
}
}
// ──────────────────────────── 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;
// Un-premultiply before encoding (same as try_set_raster_image).
let straight: Vec<u8> = pixels
.chunks_exact(4)
.flat_map(|p| {
let a = p[3];
if a == 0 {
[0u8, 0, 0, 0]
} else {
let inv = 255.0 / a as f32;
[
(p[0] as f32 * inv).round().min(255.0) as u8,
(p[1] as f32 * inv).round().min(255.0) as u8,
(p[2] as f32 * inv).round().min(255.0) as u8,
a,
]
}
})
.collect();
let img = RgbaImage::from_raw(width, height, straight)?;
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 { pub struct ClipboardManager {
/// Internal clipboard (preserves rich data without serialization loss) /// Internal clipboard (preserves rich data without serialization loss).
internal: Option<ClipboardContent>, internal: Option<ClipboardContent>,
/// System clipboard handle (lazy-initialized) /// System clipboard handle (lazy-initialized).
system: Option<arboard::Clipboard>, system: Option<arboard::Clipboard>,
} }
impl ClipboardManager { impl ClipboardManager {
/// Create a new clipboard manager /// Create a new clipboard manager.
pub fn new() -> Self { pub fn new() -> Self {
let system = arboard::Clipboard::new().ok(); let system = arboard::Clipboard::new().ok();
Self { Self { internal: None, system }
internal: None,
system,
}
} }
/// Copy content to both internal and system clipboard /// Copy content to the internal clipboard, the platform custom-MIME clipboard,
/// and the arboard text-fallback clipboard.
pub fn copy(&mut self, content: ClipboardContent) { pub fn copy(&mut self, content: ClipboardContent) {
// Serialize to system clipboard as JSON text let json = serde_json::to_string(&content).unwrap_or_default();
if let Some(system) = self.system.as_mut() {
if let Ok(json) = serde_json::to_string(&content) { // Build platform entries (custom MIME always present; PNG secondary for raster).
let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json); let mut entries: Vec<(&str, Vec<u8>)> =
let _ = system.set_text(clipboard_text); 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));
} }
} }
// Store internally for rich access // Ordering note: on macOS/Windows arboard must go first because set_text()
// calls clearContents/EmptyClipboard, after which clipboard_platform::set()
// appends the custom types. On Linux the order is reversed so that arboard
// ends up as the final clipboard owner with text/plain available — egui reads
// text/plain to generate Event::Paste for Ctrl+V.
//
// try_set_raster_image() is intentionally omitted: it calls arboard.set_image()
// which calls clearContents again, wiping the text and custom types we just set.
// The image/png entry in `entries` covers external-app image interop instead.
#[cfg(target_os = "linux")]
{
// Linux: platform first, then arboard.set_text() becomes the final owner.
clipboard_platform::set(
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
);
if let Some(sys) = self.system.as_mut() {
let _ = sys.set_text(format!("{}{}", CLIPBOARD_PREFIX, json));
}
}
#[cfg(not(target_os = "linux"))]
{
// macOS/Windows: arboard first (clears clipboard), then append custom types.
if let Some(sys) = self.system.as_mut() {
let _ = sys.set_text(format!("{}{}", CLIPBOARD_PREFIX, json));
}
clipboard_platform::set(
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
);
}
self.internal = Some(content); self.internal = Some(content);
} }
/// Try to paste content /// Try to paste content.
/// Returns internal clipboard if available, falls back to system clipboard JSON ///
/// Priority:
/// 1. Internal cache (same-process fast path).
/// 2. Platform custom MIME type (cross-process LB → LB).
/// 3. arboard text fallback (terminals, remote desktops, older LB builds).
pub fn paste(&mut self) -> Option<ClipboardContent> { pub fn paste(&mut self) -> Option<ClipboardContent> {
// Try internal clipboard first // 1. Internal cache.
if let Some(content) = &self.internal { if let Some(content) = &self.internal {
return Some(content.clone()); return Some(content.clone());
} }
// Fall back to system clipboard // 2. Platform custom MIME type.
if let Some(system) = self.system.as_mut() { if let Some((_, data)) = clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) {
if let Ok(text) = system.get_text() { if let Ok(s) = std::str::from_utf8(&data) {
if let Ok(content) = serde_json::from_str::<ClipboardContent>(s) {
return Some(content);
}
}
}
// 3. arboard text fallback.
if let Some(sys) = self.system.as_mut() {
if let Ok(text) = sys.get_text() {
if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) { if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) {
if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) { if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) {
return Some(content); return Some(content);
@ -271,25 +599,26 @@ impl ClipboardManager {
/// Copy raster pixels to the system clipboard as an image. /// Copy raster pixels to the system clipboard as an image.
/// ///
/// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes). /// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes).
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors /// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors.
/// (arboard is a temporary integration point and will be replaced).
pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) { pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) {
let Some(system) = self.system.as_mut() else { return }; let Some(system) = self.system.as_mut() else { return };
// Unpremultiply: sRGB-premul → straight RGBA8 for the system clipboard. let straight: Vec<u8> = pixels
let straight: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| { .chunks_exact(4)
let a = p[3]; .flat_map(|p| {
if a == 0 { let a = p[3];
[0u8, 0, 0, 0] if a == 0 {
} else { [0u8, 0, 0, 0]
let inv = 255.0 / a as f32; } else {
[ let inv = 255.0 / a as f32;
(p[0] as f32 * inv).round().min(255.0) as u8, [
(p[1] as f32 * inv).round().min(255.0) as u8, (p[0] as f32 * inv).round().min(255.0) as u8,
(p[2] as f32 * inv).round().min(255.0) as u8, (p[1] as f32 * inv).round().min(255.0) as u8,
a, (p[2] as f32 * inv).round().min(255.0) as u8,
] a,
} ]
}).collect(); }
})
.collect();
let img = arboard::ImageData { let img = arboard::ImageData {
width: width as usize, width: width as usize,
height: height as usize, height: height as usize,
@ -306,36 +635,37 @@ impl ClipboardManager {
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 width = img.width as u32;
let height = img.height as u32; let height = img.height as u32;
// Premultiply: straight RGBA8 → sRGB-premul. let premul: Vec<u8> = img
let premul: Vec<u8> = img.bytes.chunks_exact(4).flat_map(|p| { .bytes
let a = p[3]; .chunks_exact(4)
if a == 0 { .flat_map(|p| {
[0u8, 0, 0, 0] let a = p[3];
} else { if a == 0 {
let scale = a as f32 / 255.0; [0u8, 0, 0, 0]
[ } else {
(p[0] as f32 * scale).round() as u8, let scale = a as f32 / 255.0;
(p[1] as f32 * scale).round() as u8, [
(p[2] as f32 * scale).round() as u8, (p[0] as f32 * scale).round() as u8,
a, (p[1] as f32 * scale).round() as u8,
] (p[2] as f32 * scale).round() as u8,
} a,
}).collect(); ]
}
})
.collect();
Some((premul, width, height)) Some((premul, width, height))
} }
/// Check if there's content available to paste /// Check if there is content available to paste.
pub fn has_content(&mut self) -> bool { pub fn has_content(&mut self) -> bool {
if self.internal.is_some() { if self.internal.is_some() {
return true; return true;
} }
if let Some(sys) = self.system.as_mut() {
if let Some(system) = self.system.as_mut() { if let Ok(text) = sys.get_text() {
if let Ok(text) = system.get_text() {
return text.starts_with(CLIPBOARD_PREFIX); return text.starts_with(CLIPBOARD_PREFIX);
} }
} }
false false
} }
} }

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

@ -1973,7 +1973,6 @@ impl EditorApp {
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,
}); });
@ -2294,29 +2293,15 @@ 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, ClipboardContent::Layers { .. } => {
}; // TODO: insert copied layers as siblings at the current selection point.
}
// Add shapes to the active vector layer's keyframe ClipboardContent::AudioNodes { .. } => {
let document = self.action_executor.document_mut(); // TODO: add nodes to the target layer's audio graph with new IDs and
let layer = match document.get_layer_mut(&active_layer_id) { // sync to the DAW backend.
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => {
eprintln!("Cannot paste shapes: not a vector layer");
return;
}
};
// TODO: DCEL - paste shapes not yet implemented
let _ = (vector_layer, shapes);
} }
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
@ -2324,7 +2309,8 @@ 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 };
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,