Don't dump json into text clipboard
This commit is contained in:
parent
a45d674ed7
commit
75e35b0ac6
|
|
@ -2258,6 +2258,12 @@ version = "0.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flacenc"
|
||||
version = "0.4.0"
|
||||
|
|
@ -3443,6 +3449,9 @@ dependencies = [
|
|||
"image",
|
||||
"kurbo 0.12.0",
|
||||
"lru",
|
||||
"objc2 0.6.3",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pathdiff",
|
||||
"rstar",
|
||||
"serde",
|
||||
|
|
@ -3451,6 +3460,9 @@ dependencies = [
|
|||
"uuid",
|
||||
"vello",
|
||||
"wgpu",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11-clipboard",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
|
@ -4030,7 +4042,7 @@ version = "0.7.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
|
|
@ -4080,10 +4092,10 @@ dependencies = [
|
|||
"block2 0.5.1",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-data",
|
||||
"objc2-core-image",
|
||||
"objc2-core-data 0.2.2",
|
||||
"objc2-core-image 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-quartz-core",
|
||||
"objc2-quartz-core 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4094,10 +4106,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
|||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.3",
|
||||
"objc2-cloud-kit 0.3.2",
|
||||
"objc2-core-data 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image 0.3.2",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4138,6 +4157,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-contacts"
|
||||
version = "0.2.2"
|
||||
|
|
@ -4184,6 +4214,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
|
|
@ -4222,6 +4263,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.2.2"
|
||||
|
|
@ -4234,6 +4285,31 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
|
|
@ -4314,6 +4390,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-symbols"
|
||||
version = "0.2.2"
|
||||
|
|
@ -4333,13 +4420,13 @@ dependencies = [
|
|||
"bitflags 2.10.0",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-image",
|
||||
"objc2-cloud-kit 0.2.2",
|
||||
"objc2-core-data 0.2.2",
|
||||
"objc2-core-image 0.2.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-link-presentation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-quartz-core 0.2.2",
|
||||
"objc2-symbols",
|
||||
"objc2-uniform-type-identifiers",
|
||||
"objc2-user-notifications",
|
||||
|
|
@ -4415,6 +4502,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "outref"
|
||||
version = "0.1.0"
|
||||
|
|
@ -4593,6 +4690,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
|
|
@ -6414,6 +6522,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "ttf-parser"
|
||||
version = "0.21.1"
|
||||
|
|
@ -7711,6 +7830,24 @@ version = "0.46.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
|
|
@ -7736,6 +7873,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
|
|
|
|||
|
|
@ -47,5 +47,24 @@ rstar = "0.12"
|
|||
# System clipboard
|
||||
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]
|
||||
tiny-skia = "0.11"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//! cross-platform clipboard integration via arboard.
|
||||
//! # Content types
|
||||
//! [`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::layer::{AudioLayerType, AnyLayer};
|
||||
use crate::shape::Shape;
|
||||
use crate::clipboard_platform;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
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)]
|
||||
pub enum ClipboardLayerType {
|
||||
Vector,
|
||||
|
|
@ -21,7 +53,7 @@ pub enum 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 {
|
||||
match layer {
|
||||
AnyLayer::Vector(_) => ClipboardLayerType::Vector,
|
||||
|
|
@ -31,12 +63,12 @@ impl ClipboardLayerType {
|
|||
AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
|
||||
},
|
||||
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
||||
AnyLayer::Group(_) => ClipboardLayerType::Vector, // Groups don't have a direct clipboard type; treat as vector
|
||||
AnyLayer::Raster(_) => ClipboardLayerType::Vector, // Raster layers treated as vector for clipboard purposes
|
||||
AnyLayer::Group(_) => ClipboardLayerType::Vector,
|
||||
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 {
|
||||
match (self, layer) {
|
||||
(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)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ClipboardContent {
|
||||
/// Clip instances with their referenced clip definitions
|
||||
/// Timeline clip instances with all referenced clip definitions.
|
||||
ClipInstances {
|
||||
/// Source layer type (for paste compatibility)
|
||||
/// Source layer type (for paste compatibility).
|
||||
layer_type: ClipboardLayerType,
|
||||
/// The clip instances (IDs will be regenerated on paste)
|
||||
/// The clip instances (IDs regenerated on paste).
|
||||
instances: Vec<ClipInstance>,
|
||||
/// Referenced audio clip definitions
|
||||
/// Referenced audio clip definitions.
|
||||
audio_clips: Vec<(Uuid, AudioClip)>,
|
||||
/// Referenced video clip definitions
|
||||
/// Referenced video clip definitions.
|
||||
video_clips: Vec<(Uuid, VideoClip)>,
|
||||
/// Referenced vector clip definitions
|
||||
/// Referenced vector clip definitions.
|
||||
vector_clips: Vec<(Uuid, VectorClip)>,
|
||||
/// Referenced image assets
|
||||
/// Referenced image assets.
|
||||
image_assets: Vec<(Uuid, ImageAsset)>,
|
||||
},
|
||||
/// Shapes from a vector layer's keyframe
|
||||
Shapes {
|
||||
/// Shapes (with embedded transforms)
|
||||
shapes: Vec<Shape>,
|
||||
|
||||
/// Selected DCEL geometry from a vector layer.
|
||||
///
|
||||
/// 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 {
|
||||
/// 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)>,
|
||||
},
|
||||
/// 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 —
|
||||
/// 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 {
|
||||
pixels: Vec<u8>,
|
||||
width: 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 {
|
||||
/// Create a clone of this content with all UUIDs regenerated
|
||||
/// Returns the new content and a mapping from old to new IDs
|
||||
/// Clone this content with all UUIDs regenerated.
|
||||
///
|
||||
/// Returns the new content and a mapping from old → new IDs.
|
||||
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 {
|
||||
// ── ClipInstances ───────────────────────────────────────────────
|
||||
ClipboardContent::ClipInstances {
|
||||
layer_type,
|
||||
instances,
|
||||
|
|
@ -107,67 +209,11 @@ impl ClipboardContent {
|
|||
vector_clips,
|
||||
image_assets,
|
||||
} => {
|
||||
// Regenerate clip definition IDs
|
||||
let new_audio_clips: Vec<(Uuid, AudioClip)> = audio_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_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();
|
||||
|
||||
let new_audio_clips = regen_audio_clips(audio_clips, &mut id_map);
|
||||
let new_video_clips = regen_video_clips(video_clips, &mut id_map);
|
||||
let new_vector_clips = regen_vector_clips(vector_clips, &mut id_map);
|
||||
let new_image_assets = regen_image_assets(image_assets, &mut id_map);
|
||||
let new_instances = regen_clip_instances(instances, &mut id_map);
|
||||
(
|
||||
ClipboardContent::ClipInstances {
|
||||
layer_type: layer_type.clone(),
|
||||
|
|
@ -180,29 +226,82 @@ impl ClipboardContent {
|
|||
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 } => {
|
||||
// No IDs to regenerate, just clone
|
||||
(ClipboardContent::MidiNotes { notes: notes.clone() }, id_map)
|
||||
}
|
||||
ClipboardContent::RasterPixels { pixels, width, height } => {
|
||||
(ClipboardContent::RasterPixels { pixels: pixels.clone(), width: *width, height: *height }, id_map)
|
||||
}
|
||||
ClipboardContent::Shapes { shapes } => {
|
||||
// Regenerate shape IDs
|
||||
let new_shapes: Vec<Shape> = shapes
|
||||
|
||||
// ── RasterPixels ────────────────────────────────────────────────
|
||||
ClipboardContent::RasterPixels { pixels, width, height } => (
|
||||
ClipboardContent::RasterPixels {
|
||||
pixels: pixels.clone(),
|
||||
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()
|
||||
.map(|shape| {
|
||||
let new_id = Uuid::new_v4();
|
||||
id_map.insert(shape.id, new_id);
|
||||
let mut new_shape = shape.clone();
|
||||
new_shape.id = new_id;
|
||||
new_shape
|
||||
.map(|l| regen_any_layer(l, &mut id_map))
|
||||
.collect();
|
||||
// Remap raster_pixels layer_id keys.
|
||||
let new_raster: Vec<(Uuid, u64, Vec<u8>)> = raster_pixels
|
||||
.iter()
|
||||
.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();
|
||||
|
||||
(
|
||||
ClipboardContent::Shapes {
|
||||
shapes: new_shapes,
|
||||
ClipboardContent::Layers {
|
||||
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,
|
||||
)
|
||||
|
|
@ -211,52 +310,281 @@ impl ClipboardContent {
|
|||
}
|
||||
}
|
||||
|
||||
/// JSON prefix for clipboard text to identify Lightningbeam content
|
||||
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
|
||||
// ──────────────────────── ID regeneration helpers ───────────────────────────
|
||||
|
||||
/// 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 {
|
||||
/// Internal clipboard (preserves rich data without serialization loss)
|
||||
/// Internal clipboard (preserves rich data without serialization loss).
|
||||
internal: Option<ClipboardContent>,
|
||||
/// System clipboard handle (lazy-initialized)
|
||||
/// System clipboard handle (lazy-initialized).
|
||||
system: Option<arboard::Clipboard>,
|
||||
}
|
||||
|
||||
impl ClipboardManager {
|
||||
/// Create a new clipboard manager
|
||||
/// Create a new clipboard manager.
|
||||
pub fn new() -> Self {
|
||||
let system = arboard::Clipboard::new().ok();
|
||||
Self {
|
||||
internal: None,
|
||||
system,
|
||||
}
|
||||
Self { 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) {
|
||||
// Serialize to system clipboard as JSON text
|
||||
if let Some(system) = self.system.as_mut() {
|
||||
if let Ok(json) = serde_json::to_string(&content) {
|
||||
let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json);
|
||||
let _ = system.set_text(clipboard_text);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// Try to paste content
|
||||
/// Returns internal clipboard if available, falls back to system clipboard JSON
|
||||
/// Try to paste content.
|
||||
///
|
||||
/// 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> {
|
||||
// Try internal clipboard first
|
||||
// 1. Internal cache.
|
||||
if let Some(content) = &self.internal {
|
||||
return Some(content.clone());
|
||||
}
|
||||
|
||||
// Fall back to system clipboard
|
||||
if let Some(system) = self.system.as_mut() {
|
||||
if let Ok(text) = system.get_text() {
|
||||
// 2. Platform custom MIME type.
|
||||
if let Some((_, data)) = clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) {
|
||||
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 Ok(content) = serde_json::from_str::<ClipboardContent>(json) {
|
||||
return Some(content);
|
||||
|
|
@ -271,25 +599,26 @@ impl ClipboardManager {
|
|||
/// Copy raster pixels to the system clipboard as an image.
|
||||
///
|
||||
/// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes).
|
||||
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors
|
||||
/// (arboard is a temporary integration point and will be replaced).
|
||||
/// 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 };
|
||||
// Unpremultiply: sRGB-premul → straight RGBA8 for the system clipboard.
|
||||
let straight: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
|
||||
let a = p[3];
|
||||
if a == 0 {
|
||||
[0u8, 0, 0, 0]
|
||||
} else {
|
||||
let inv = 255.0 / a as f32;
|
||||
[
|
||||
(p[0] as f32 * inv).round().min(255.0) as u8,
|
||||
(p[1] as f32 * inv).round().min(255.0) as u8,
|
||||
(p[2] as f32 * inv).round().min(255.0) as u8,
|
||||
a,
|
||||
]
|
||||
}
|
||||
}).collect();
|
||||
let straight: Vec<u8> = pixels
|
||||
.chunks_exact(4)
|
||||
.flat_map(|p| {
|
||||
let a = p[3];
|
||||
if a == 0 {
|
||||
[0u8, 0, 0, 0]
|
||||
} else {
|
||||
let inv = 255.0 / a as f32;
|
||||
[
|
||||
(p[0] as f32 * inv).round().min(255.0) as u8,
|
||||
(p[1] as f32 * inv).round().min(255.0) as u8,
|
||||
(p[2] as f32 * inv).round().min(255.0) as u8,
|
||||
a,
|
||||
]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let img = arboard::ImageData {
|
||||
width: width as usize,
|
||||
height: height as usize,
|
||||
|
|
@ -306,36 +635,37 @@ impl ClipboardManager {
|
|||
let img = self.system.as_mut()?.get_image().ok()?;
|
||||
let width = img.width as u32;
|
||||
let height = img.height as u32;
|
||||
// Premultiply: straight RGBA8 → sRGB-premul.
|
||||
let premul: Vec<u8> = img.bytes.chunks_exact(4).flat_map(|p| {
|
||||
let a = p[3];
|
||||
if a == 0 {
|
||||
[0u8, 0, 0, 0]
|
||||
} else {
|
||||
let scale = a as f32 / 255.0;
|
||||
[
|
||||
(p[0] as f32 * scale).round() as u8,
|
||||
(p[1] as f32 * scale).round() as u8,
|
||||
(p[2] as f32 * scale).round() as u8,
|
||||
a,
|
||||
]
|
||||
}
|
||||
}).collect();
|
||||
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
|
||||
/// Check if there is 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() {
|
||||
if let Some(sys) = self.system.as_mut() {
|
||||
if let Ok(text) = sys.get_text() {
|
||||
return text.starts_with(CLIPBOARD_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ pub mod file_types;
|
|||
pub mod file_io;
|
||||
pub mod export;
|
||||
pub mod clipboard;
|
||||
pub(crate) mod clipboard_platform;
|
||||
pub mod region_select;
|
||||
pub mod dcel2;
|
||||
pub use dcel2 as dcel;
|
||||
|
|
|
|||
|
|
@ -1973,7 +1973,6 @@ impl EditorApp {
|
|||
let (pixels, w, h) = Self::extract_raster_selection(
|
||||
&kf.raw_pixels, kf.width, kf.height, raster_sel,
|
||||
);
|
||||
self.clipboard_manager.try_set_raster_image(&pixels, w, h);
|
||||
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
||||
pixels, width: w, height: h,
|
||||
});
|
||||
|
|
@ -2294,29 +2293,15 @@ impl EditorApp {
|
|||
self.selection.add_clip_instance(id);
|
||||
}
|
||||
}
|
||||
ClipboardContent::Shapes { shapes } => {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: DCEL - paste shapes not yet implemented
|
||||
let _ = (vector_layer, shapes);
|
||||
ClipboardContent::VectorGeometry { .. } => {
|
||||
// TODO (Phase 2): paste DCEL subgraph once vector serialization is defined.
|
||||
}
|
||||
ClipboardContent::Layers { .. } => {
|
||||
// TODO: insert copied layers as siblings at the current selection point.
|
||||
}
|
||||
ClipboardContent::AudioNodes { .. } => {
|
||||
// TODO: add nodes to the target layer's audio graph with new IDs and
|
||||
// sync to the DAW backend.
|
||||
}
|
||||
ClipboardContent::MidiNotes { .. } => {
|
||||
// MIDI notes are pasted directly in the piano roll pane, not here
|
||||
|
|
@ -2324,7 +2309,8 @@ impl EditorApp {
|
|||
ClipboardContent::RasterPixels { pixels, width, height } => {
|
||||
let Some(layer_id) = self.active_layer_id else { return };
|
||||
let document = self.action_executor.document();
|
||||
let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) else { return };
|
||||
let 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 };
|
||||
|
||||
// Paste position: top-left of the current raster selection if any,
|
||||
|
|
|
|||
Loading…
Reference in New Issue