Compare commits
5 Commits
a45d674ed7
...
73ef9e3b9c
| Author | SHA1 | Date |
|---|---|---|
|
|
73ef9e3b9c | |
|
|
6f1a706dd2 | |
|
|
19617e4223 | |
|
|
c1266c0377 | |
|
|
75e35b0ac6 |
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,45 @@
|
||||||
//! Clipboard management for cut/copy/paste operations
|
//! Clipboard management for cut/copy/paste operations.
|
||||||
//!
|
//!
|
||||||
//! Supports multiple content types (clip instances, shapes) with
|
//! # Content types
|
||||||
//! cross-platform clipboard integration via arboard.
|
//! [`ClipboardContent`] covers every selectable item in the app:
|
||||||
|
//! - [`ClipInstances`](ClipboardContent::ClipInstances) — timeline clips
|
||||||
|
//! - [`VectorGeometry`](ClipboardContent::VectorGeometry) — DCEL shapes (stub; Phase 2)
|
||||||
|
//! - [`MidiNotes`](ClipboardContent::MidiNotes) — piano-roll notes
|
||||||
|
//! - [`RasterPixels`](ClipboardContent::RasterPixels) — raster selection
|
||||||
|
//! - [`Layers`](ClipboardContent::Layers) — complete layer subtrees
|
||||||
|
//! - [`AudioNodes`](ClipboardContent::AudioNodes) — audio-graph node subgraph
|
||||||
|
//!
|
||||||
|
//! # Storage strategy
|
||||||
|
//! Content is kept in three places simultaneously:
|
||||||
|
//! 1. **Internal** (`self.internal`) — in-process, zero-copy, always preferred.
|
||||||
|
//! 2. **Platform custom type** (`application/x-lightningbeam`) via
|
||||||
|
//! [`crate::clipboard_platform`] — enables cross-process paste between LB windows.
|
||||||
|
//! 3. **arboard text fallback** — `LIGHTNINGBEAM_CLIPBOARD:<json>` in the system
|
||||||
|
//! text clipboard for maximum compatibility (e.g. terminals, remote desktops).
|
||||||
|
//!
|
||||||
|
//! For `RasterPixels` an additional `image/png` entry is set on macOS and Windows
|
||||||
|
//! so the image can be pasted into external apps.
|
||||||
|
//!
|
||||||
|
//! # Temporary note
|
||||||
|
//! The custom-MIME platform layer ([`crate::clipboard_platform`]) is a shim until
|
||||||
|
//! arboard supports custom MIME types natively
|
||||||
|
//! (<https://github.com/1Password/arboard/issues/14>). When that lands, remove
|
||||||
|
//! `clipboard_platform`, the `objc2*` and `windows-sys` Cargo deps, and call
|
||||||
|
//! arboard directly.
|
||||||
|
|
||||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
|
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
|
||||||
use crate::layer::{AudioLayerType, AnyLayer};
|
use crate::layer::{AudioLayerType, AnyLayer};
|
||||||
use crate::shape::Shape;
|
use crate::clipboard_platform;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Layer type tag for clipboard, so paste knows where clips can go
|
/// MIME type used for cross-process Lightningbeam clipboard data.
|
||||||
|
pub const LIGHTNINGBEAM_MIME: &str = "application/x-lightningbeam";
|
||||||
|
|
||||||
|
// ─────────────────────────────── Layer type tag ─────────────────────────────
|
||||||
|
|
||||||
|
/// Layer type tag for clipboard — tells paste where clip instances can go.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum ClipboardLayerType {
|
pub enum ClipboardLayerType {
|
||||||
Vector,
|
Vector,
|
||||||
|
|
@ -21,7 +50,7 @@ pub enum ClipboardLayerType {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClipboardLayerType {
|
impl ClipboardLayerType {
|
||||||
/// Determine the clipboard layer type from a document layer
|
/// Determine the clipboard layer type from a document layer.
|
||||||
pub fn from_layer(layer: &AnyLayer) -> Self {
|
pub fn from_layer(layer: &AnyLayer) -> Self {
|
||||||
match layer {
|
match layer {
|
||||||
AnyLayer::Vector(_) => ClipboardLayerType::Vector,
|
AnyLayer::Vector(_) => ClipboardLayerType::Vector,
|
||||||
|
|
@ -31,12 +60,12 @@ impl ClipboardLayerType {
|
||||||
AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
|
AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
|
||||||
},
|
},
|
||||||
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
||||||
AnyLayer::Group(_) => ClipboardLayerType::Vector, // Groups don't have a direct clipboard type; treat as vector
|
AnyLayer::Group(_) => ClipboardLayerType::Vector,
|
||||||
AnyLayer::Raster(_) => ClipboardLayerType::Vector, // Raster layers treated as vector for clipboard purposes
|
AnyLayer::Raster(_) => ClipboardLayerType::Vector,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a layer is compatible with this clipboard layer type
|
/// Check if a layer is compatible with this clipboard layer type.
|
||||||
pub fn is_compatible(&self, layer: &AnyLayer) -> bool {
|
pub fn is_compatible(&self, layer: &AnyLayer) -> bool {
|
||||||
match (self, layer) {
|
match (self, layer) {
|
||||||
(ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true,
|
(ClipboardLayerType::Vector, AnyLayer::Vector(_)) => true,
|
||||||
|
|
@ -53,52 +82,122 @@ impl ClipboardLayerType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content stored in the clipboard
|
// ──────────────────────────── Shared clip bundle ─────────────────────────────
|
||||||
|
|
||||||
|
/// Clip definitions referenced by clipboard content.
|
||||||
|
///
|
||||||
|
/// Shared between [`ClipboardContent::ClipInstances`] and [`ClipboardContent::Layers`].
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ReferencedClips {
|
||||||
|
pub audio_clips: Vec<(Uuid, AudioClip)>,
|
||||||
|
pub video_clips: Vec<(Uuid, VideoClip)>,
|
||||||
|
pub vector_clips: Vec<(Uuid, VectorClip)>,
|
||||||
|
pub image_assets: Vec<(Uuid, ImageAsset)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── Clipboard content ─────────────────────────────
|
||||||
|
|
||||||
|
/// Content stored in the clipboard.
|
||||||
|
///
|
||||||
|
/// The `serde(tag = "type")` discriminant is stable — unknown variants
|
||||||
|
/// deserialize as `None`, so new variants can be added without breaking
|
||||||
|
/// existing serialized data.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum ClipboardContent {
|
pub enum ClipboardContent {
|
||||||
/// Clip instances with their referenced clip definitions
|
/// Timeline clip instances with all referenced clip definitions.
|
||||||
ClipInstances {
|
ClipInstances {
|
||||||
/// Source layer type (for paste compatibility)
|
/// Source layer type (for paste compatibility).
|
||||||
layer_type: ClipboardLayerType,
|
layer_type: ClipboardLayerType,
|
||||||
/// The clip instances (IDs will be regenerated on paste)
|
/// The clip instances (IDs regenerated on paste).
|
||||||
instances: Vec<ClipInstance>,
|
instances: Vec<ClipInstance>,
|
||||||
/// Referenced audio clip definitions
|
/// Referenced audio clip definitions.
|
||||||
audio_clips: Vec<(Uuid, AudioClip)>,
|
audio_clips: Vec<(Uuid, AudioClip)>,
|
||||||
/// Referenced video clip definitions
|
/// Referenced video clip definitions.
|
||||||
video_clips: Vec<(Uuid, VideoClip)>,
|
video_clips: Vec<(Uuid, VideoClip)>,
|
||||||
/// Referenced vector clip definitions
|
/// Referenced vector clip definitions.
|
||||||
vector_clips: Vec<(Uuid, VectorClip)>,
|
vector_clips: Vec<(Uuid, VectorClip)>,
|
||||||
/// Referenced image assets
|
/// Referenced image assets.
|
||||||
image_assets: Vec<(Uuid, ImageAsset)>,
|
image_assets: Vec<(Uuid, ImageAsset)>,
|
||||||
},
|
},
|
||||||
/// Shapes from a vector layer's keyframe
|
|
||||||
Shapes {
|
/// Selected DCEL geometry from a vector layer.
|
||||||
/// Shapes (with embedded transforms)
|
///
|
||||||
shapes: Vec<Shape>,
|
/// Currently a stub — `data` is opaque bytes whose format is TBD in Phase 2
|
||||||
|
/// once DCEL serialization is implemented. Copy/paste of vector shapes does
|
||||||
|
/// nothing until then. Secondary formats (`image/svg+xml`, `image/png`) are
|
||||||
|
/// also deferred to Phase 2.
|
||||||
|
VectorGeometry {
|
||||||
|
/// Opaque DCEL subgraph bytes (format TBD, Phase 2).
|
||||||
|
data: Vec<u8>,
|
||||||
},
|
},
|
||||||
/// MIDI notes from the piano roll
|
|
||||||
|
/// MIDI notes from the piano roll.
|
||||||
MidiNotes {
|
MidiNotes {
|
||||||
/// Notes: (start_time, note, velocity, duration) — times relative to selection start
|
/// `(start_time, note, velocity, duration)` — times relative to selection start.
|
||||||
notes: Vec<(f64, u8, u8, f64)>,
|
notes: Vec<(f64, u8, u8, f64)>,
|
||||||
},
|
},
|
||||||
/// Raw pixel data from a raster layer selection.
|
|
||||||
|
/// Raw pixel region from a raster layer selection.
|
||||||
|
///
|
||||||
/// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes —
|
/// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes —
|
||||||
/// the same in-memory format as `RasterKeyframe::raw_pixels`.
|
/// the same in-memory format as `RasterKeyframe::raw_pixels`.
|
||||||
|
///
|
||||||
|
/// On macOS and Windows an `image/png` secondary format is also set so the
|
||||||
|
/// content can be pasted into external apps.
|
||||||
RasterPixels {
|
RasterPixels {
|
||||||
pixels: Vec<u8>,
|
pixels: Vec<u8>,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// One or more complete layers copied from the timeline.
|
||||||
|
///
|
||||||
|
/// [`AnyLayer`] derives `Serialize`/`Deserialize`; only
|
||||||
|
/// `RasterKeyframe::raw_pixels` is excluded from serde (`#[serde(skip)]`) and
|
||||||
|
/// is therefore carried separately in `raster_pixels`.
|
||||||
|
///
|
||||||
|
/// On paste: insert as sibling layers at the current selection point with all
|
||||||
|
/// UUIDs regenerated.
|
||||||
|
Layers {
|
||||||
|
/// Complete serialized layer trees (raw_pixels absent).
|
||||||
|
layers: Vec<AnyLayer>,
|
||||||
|
/// Raster pixel data keyed by `(layer_id, time.to_bits())`.
|
||||||
|
/// Restored into `RasterKeyframe::raw_pixels` after deserialization by
|
||||||
|
/// matching layer_id + time_bits against the deserialized keyframes.
|
||||||
|
raster_pixels: Vec<(Uuid, u64, Vec<u8>)>,
|
||||||
|
/// All clip definitions referenced by any of the copied layers.
|
||||||
|
referenced_clips: ReferencedClips,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Selected nodes and edges from an audio effect/synthesis graph.
|
||||||
|
///
|
||||||
|
/// Uses the same serialization types as preset save/load
|
||||||
|
/// (`daw_backend::audio::node_graph::preset`).
|
||||||
|
///
|
||||||
|
/// On paste: add nodes to the target layer's graph with new IDs, then sync
|
||||||
|
/// to the DAW backend (same pattern as `ClipInstances`).
|
||||||
|
AudioNodes {
|
||||||
|
/// Selected nodes.
|
||||||
|
nodes: Vec<daw_backend::SerializedNode>,
|
||||||
|
/// Connections between the selected nodes only.
|
||||||
|
connections: Vec<daw_backend::SerializedConnection>,
|
||||||
|
/// Source layer UUID — hint for paste target validation.
|
||||||
|
source_layer_id: Uuid,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────── ID regeneration ───────────────────────────────────
|
||||||
|
|
||||||
impl ClipboardContent {
|
impl ClipboardContent {
|
||||||
/// Create a clone of this content with all UUIDs regenerated
|
/// Clone this content with all UUIDs regenerated.
|
||||||
/// Returns the new content and a mapping from old to new IDs
|
///
|
||||||
|
/// Returns the new content and a mapping from old → new IDs.
|
||||||
pub fn with_regenerated_ids(&self) -> (Self, HashMap<Uuid, Uuid>) {
|
pub fn with_regenerated_ids(&self) -> (Self, HashMap<Uuid, Uuid>) {
|
||||||
let mut id_map = HashMap::new();
|
let mut id_map: HashMap<Uuid, Uuid> = HashMap::new();
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
|
// ── ClipInstances ───────────────────────────────────────────────
|
||||||
ClipboardContent::ClipInstances {
|
ClipboardContent::ClipInstances {
|
||||||
layer_type,
|
layer_type,
|
||||||
instances,
|
instances,
|
||||||
|
|
@ -107,67 +206,11 @@ impl ClipboardContent {
|
||||||
vector_clips,
|
vector_clips,
|
||||||
image_assets,
|
image_assets,
|
||||||
} => {
|
} => {
|
||||||
// Regenerate clip definition IDs
|
let new_audio_clips = regen_audio_clips(audio_clips, &mut id_map);
|
||||||
let new_audio_clips: Vec<(Uuid, AudioClip)> = audio_clips
|
let new_video_clips = regen_video_clips(video_clips, &mut id_map);
|
||||||
.iter()
|
let new_vector_clips = regen_vector_clips(vector_clips, &mut id_map);
|
||||||
.map(|(old_id, clip)| {
|
let new_image_assets = regen_image_assets(image_assets, &mut id_map);
|
||||||
let new_id = Uuid::new_v4();
|
let new_instances = regen_clip_instances(instances, &mut id_map);
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_clip = clip.clone();
|
|
||||||
new_clip.id = new_id;
|
|
||||||
(new_id, new_clip)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let new_video_clips: Vec<(Uuid, VideoClip)> = video_clips
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, clip)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_clip = clip.clone();
|
|
||||||
new_clip.id = new_id;
|
|
||||||
(new_id, new_clip)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let new_vector_clips: Vec<(Uuid, VectorClip)> = vector_clips
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, clip)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_clip = clip.clone();
|
|
||||||
new_clip.id = new_id;
|
|
||||||
(new_id, new_clip)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let new_image_assets: Vec<(Uuid, ImageAsset)> = image_assets
|
|
||||||
.iter()
|
|
||||||
.map(|(old_id, asset)| {
|
|
||||||
let new_id = Uuid::new_v4();
|
|
||||||
id_map.insert(*old_id, new_id);
|
|
||||||
let mut new_asset = asset.clone();
|
|
||||||
new_asset.id = new_id;
|
|
||||||
(new_id, new_asset)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Regenerate clip instance IDs and remap clip_id references
|
|
||||||
let new_instances: Vec<ClipInstance> = instances
|
|
||||||
.iter()
|
|
||||||
.map(|inst| {
|
|
||||||
let new_instance_id = Uuid::new_v4();
|
|
||||||
id_map.insert(inst.id, new_instance_id);
|
|
||||||
let mut new_inst = inst.clone();
|
|
||||||
new_inst.id = new_instance_id;
|
|
||||||
// Remap clip_id to new definition ID
|
|
||||||
if let Some(new_clip_id) = id_map.get(&inst.clip_id) {
|
|
||||||
new_inst.clip_id = *new_clip_id;
|
|
||||||
}
|
|
||||||
new_inst
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(
|
(
|
||||||
ClipboardContent::ClipInstances {
|
ClipboardContent::ClipInstances {
|
||||||
layer_type: layer_type.clone(),
|
layer_type: layer_type.clone(),
|
||||||
|
|
@ -180,29 +223,82 @@ impl ClipboardContent {
|
||||||
id_map,
|
id_map,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── VectorGeometry ──────────────────────────────────────────────
|
||||||
|
// TODO (Phase 2): remap DCEL vertex/edge UUIDs once DCEL serialization
|
||||||
|
// is defined.
|
||||||
|
ClipboardContent::VectorGeometry { data } => {
|
||||||
|
(ClipboardContent::VectorGeometry { data: data.clone() }, id_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MidiNotes ───────────────────────────────────────────────────
|
||||||
ClipboardContent::MidiNotes { notes } => {
|
ClipboardContent::MidiNotes { notes } => {
|
||||||
// No IDs to regenerate, just clone
|
|
||||||
(ClipboardContent::MidiNotes { notes: notes.clone() }, id_map)
|
(ClipboardContent::MidiNotes { notes: notes.clone() }, id_map)
|
||||||
}
|
}
|
||||||
ClipboardContent::RasterPixels { pixels, width, height } => {
|
|
||||||
(ClipboardContent::RasterPixels { pixels: pixels.clone(), width: *width, height: *height }, id_map)
|
// ── RasterPixels ────────────────────────────────────────────────
|
||||||
}
|
ClipboardContent::RasterPixels { pixels, width, height } => (
|
||||||
ClipboardContent::Shapes { shapes } => {
|
ClipboardContent::RasterPixels {
|
||||||
// Regenerate shape IDs
|
pixels: pixels.clone(),
|
||||||
let new_shapes: Vec<Shape> = shapes
|
width: *width,
|
||||||
|
height: *height,
|
||||||
|
},
|
||||||
|
id_map,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Layers ──────────────────────────────────────────────────────
|
||||||
|
ClipboardContent::Layers { layers, raster_pixels, referenced_clips } => {
|
||||||
|
let new_clips = regen_referenced_clips(referenced_clips, &mut id_map);
|
||||||
|
let new_layers: Vec<AnyLayer> = layers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|shape| {
|
.map(|l| regen_any_layer(l, &mut id_map))
|
||||||
let new_id = Uuid::new_v4();
|
.collect();
|
||||||
id_map.insert(shape.id, new_id);
|
// Remap raster_pixels layer_id keys.
|
||||||
let mut new_shape = shape.clone();
|
let new_raster: Vec<(Uuid, u64, Vec<u8>)> = raster_pixels
|
||||||
new_shape.id = new_id;
|
.iter()
|
||||||
new_shape
|
.map(|(old_lid, time_bits, px)| {
|
||||||
|
let new_lid = id_map.get(old_lid).copied().unwrap_or(*old_lid);
|
||||||
|
(new_lid, *time_bits, px.clone())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
(
|
(
|
||||||
ClipboardContent::Shapes {
|
ClipboardContent::Layers {
|
||||||
shapes: new_shapes,
|
layers: new_layers,
|
||||||
|
raster_pixels: new_raster,
|
||||||
|
referenced_clips: new_clips,
|
||||||
|
},
|
||||||
|
id_map,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AudioNodes ──────────────────────────────────────────────────
|
||||||
|
ClipboardContent::AudioNodes { nodes, connections, source_layer_id } => {
|
||||||
|
// Remap u32 node IDs.
|
||||||
|
let mut node_id_map: HashMap<u32, u32> = HashMap::new();
|
||||||
|
let new_nodes: Vec<daw_backend::SerializedNode> = nodes
|
||||||
|
.iter()
|
||||||
|
.map(|n| {
|
||||||
|
let new_id = node_id_map.len() as u32 + 1;
|
||||||
|
node_id_map.insert(n.id, new_id);
|
||||||
|
let mut nn = n.clone();
|
||||||
|
nn.id = new_id;
|
||||||
|
nn
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let new_connections: Vec<daw_backend::SerializedConnection> = connections
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let mut nc = c.clone();
|
||||||
|
nc.from_node = node_id_map.get(&c.from_node).copied().unwrap_or(c.from_node);
|
||||||
|
nc.to_node = node_id_map.get(&c.to_node).copied().unwrap_or(c.to_node);
|
||||||
|
nc
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(
|
||||||
|
ClipboardContent::AudioNodes {
|
||||||
|
nodes: new_nodes,
|
||||||
|
connections: new_connections,
|
||||||
|
source_layer_id: *source_layer_id,
|
||||||
},
|
},
|
||||||
id_map,
|
id_map,
|
||||||
)
|
)
|
||||||
|
|
@ -211,72 +307,184 @@ impl ClipboardContent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON prefix for clipboard text to identify Lightningbeam content
|
// ──────────────────────── ID regeneration helpers ───────────────────────────
|
||||||
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
|
|
||||||
|
|
||||||
/// Manages clipboard operations with internal + system clipboard
|
fn regen_audio_clips(
|
||||||
pub struct ClipboardManager {
|
clips: &[(Uuid, AudioClip)],
|
||||||
/// Internal clipboard (preserves rich data without serialization loss)
|
id_map: &mut HashMap<Uuid, Uuid>,
|
||||||
internal: Option<ClipboardContent>,
|
) -> Vec<(Uuid, AudioClip)> {
|
||||||
/// System clipboard handle (lazy-initialized)
|
clips
|
||||||
system: Option<arboard::Clipboard>,
|
.iter()
|
||||||
|
.map(|(old_id, clip)| {
|
||||||
|
let new_id = Uuid::new_v4();
|
||||||
|
id_map.insert(*old_id, new_id);
|
||||||
|
let mut c = clip.clone();
|
||||||
|
c.id = new_id;
|
||||||
|
(new_id, c)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClipboardManager {
|
fn regen_video_clips(
|
||||||
/// Create a new clipboard manager
|
clips: &[(Uuid, crate::clip::VideoClip)],
|
||||||
pub fn new() -> Self {
|
id_map: &mut HashMap<Uuid, Uuid>,
|
||||||
let system = arboard::Clipboard::new().ok();
|
) -> Vec<(Uuid, crate::clip::VideoClip)> {
|
||||||
Self {
|
clips
|
||||||
internal: None,
|
.iter()
|
||||||
system,
|
.map(|(old_id, clip)| {
|
||||||
|
let new_id = Uuid::new_v4();
|
||||||
|
id_map.insert(*old_id, new_id);
|
||||||
|
let mut c = clip.clone();
|
||||||
|
c.id = new_id;
|
||||||
|
(new_id, c)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regen_vector_clips(
|
||||||
|
clips: &[(Uuid, VectorClip)],
|
||||||
|
id_map: &mut HashMap<Uuid, Uuid>,
|
||||||
|
) -> Vec<(Uuid, VectorClip)> {
|
||||||
|
clips
|
||||||
|
.iter()
|
||||||
|
.map(|(old_id, clip)| {
|
||||||
|
let new_id = Uuid::new_v4();
|
||||||
|
id_map.insert(*old_id, new_id);
|
||||||
|
let mut c = clip.clone();
|
||||||
|
c.id = new_id;
|
||||||
|
(new_id, c)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regen_image_assets(
|
||||||
|
assets: &[(Uuid, ImageAsset)],
|
||||||
|
id_map: &mut HashMap<Uuid, Uuid>,
|
||||||
|
) -> Vec<(Uuid, ImageAsset)> {
|
||||||
|
assets
|
||||||
|
.iter()
|
||||||
|
.map(|(old_id, asset)| {
|
||||||
|
let new_id = Uuid::new_v4();
|
||||||
|
id_map.insert(*old_id, new_id);
|
||||||
|
let mut a = asset.clone();
|
||||||
|
a.id = new_id;
|
||||||
|
(new_id, a)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regen_clip_instances(
|
||||||
|
instances: &[ClipInstance],
|
||||||
|
id_map: &mut HashMap<Uuid, Uuid>,
|
||||||
|
) -> Vec<ClipInstance> {
|
||||||
|
instances
|
||||||
|
.iter()
|
||||||
|
.map(|inst| {
|
||||||
|
let new_id = Uuid::new_v4();
|
||||||
|
id_map.insert(inst.id, new_id);
|
||||||
|
let mut i = inst.clone();
|
||||||
|
i.id = new_id;
|
||||||
|
if let Some(new_clip_id) = id_map.get(&inst.clip_id) {
|
||||||
|
i.clip_id = *new_clip_id;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regen_referenced_clips(
|
||||||
|
rc: &ReferencedClips,
|
||||||
|
id_map: &mut HashMap<Uuid, Uuid>,
|
||||||
|
) -> ReferencedClips {
|
||||||
|
ReferencedClips {
|
||||||
|
audio_clips: regen_audio_clips(&rc.audio_clips, id_map),
|
||||||
|
video_clips: regen_video_clips(&rc.video_clips, id_map),
|
||||||
|
vector_clips: regen_vector_clips(&rc.vector_clips, id_map),
|
||||||
|
image_assets: regen_image_assets(&rc.image_assets, id_map),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy content to both internal and system clipboard
|
/// Regenerate the layer's own ID (and all descendant IDs for group layers).
|
||||||
pub fn copy(&mut self, content: ClipboardContent) {
|
fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap<Uuid, Uuid>) -> AnyLayer {
|
||||||
// Serialize to system clipboard as JSON text
|
match layer {
|
||||||
if let Some(system) = self.system.as_mut() {
|
AnyLayer::Vector(vl) => {
|
||||||
if let Ok(json) = serde_json::to_string(&content) {
|
let new_layer_id = Uuid::new_v4();
|
||||||
let clipboard_text = format!("{}{}", CLIPBOARD_PREFIX, json);
|
id_map.insert(vl.layer.id, new_layer_id);
|
||||||
let _ = system.set_text(clipboard_text);
|
let mut nl = vl.clone();
|
||||||
|
nl.layer.id = new_layer_id;
|
||||||
|
nl.clip_instances = regen_clip_instances(&vl.clip_instances, id_map);
|
||||||
|
AnyLayer::Vector(nl)
|
||||||
}
|
}
|
||||||
|
AnyLayer::Audio(al) => {
|
||||||
|
let new_layer_id = Uuid::new_v4();
|
||||||
|
id_map.insert(al.layer.id, new_layer_id);
|
||||||
|
let mut nl = al.clone();
|
||||||
|
nl.layer.id = new_layer_id;
|
||||||
|
nl.clip_instances = regen_clip_instances(&al.clip_instances, id_map);
|
||||||
|
AnyLayer::Audio(nl)
|
||||||
}
|
}
|
||||||
|
AnyLayer::Video(vl) => {
|
||||||
// Store internally for rich access
|
let new_layer_id = Uuid::new_v4();
|
||||||
self.internal = Some(content);
|
id_map.insert(vl.layer.id, new_layer_id);
|
||||||
|
let mut nl = vl.clone();
|
||||||
|
nl.layer.id = new_layer_id;
|
||||||
|
nl.clip_instances = regen_clip_instances(&vl.clip_instances, id_map);
|
||||||
|
AnyLayer::Video(nl)
|
||||||
}
|
}
|
||||||
|
AnyLayer::Effect(el) => {
|
||||||
/// Try to paste content
|
let new_layer_id = Uuid::new_v4();
|
||||||
/// Returns internal clipboard if available, falls back to system clipboard JSON
|
id_map.insert(el.layer.id, new_layer_id);
|
||||||
pub fn paste(&mut self) -> Option<ClipboardContent> {
|
let mut nl = el.clone();
|
||||||
// Try internal clipboard first
|
nl.layer.id = new_layer_id;
|
||||||
if let Some(content) = &self.internal {
|
nl.clip_instances = regen_clip_instances(&el.clip_instances, id_map);
|
||||||
return Some(content.clone());
|
AnyLayer::Effect(nl)
|
||||||
}
|
}
|
||||||
|
AnyLayer::Raster(rl) => {
|
||||||
// Fall back to system clipboard
|
let new_layer_id = Uuid::new_v4();
|
||||||
if let Some(system) = self.system.as_mut() {
|
id_map.insert(rl.layer.id, new_layer_id);
|
||||||
if let Ok(text) = system.get_text() {
|
let mut nl = rl.clone();
|
||||||
if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) {
|
nl.layer.id = new_layer_id;
|
||||||
if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) {
|
AnyLayer::Raster(nl)
|
||||||
return Some(content);
|
|
||||||
}
|
}
|
||||||
|
AnyLayer::Group(gl) => {
|
||||||
|
let new_layer_id = Uuid::new_v4();
|
||||||
|
id_map.insert(gl.layer.id, new_layer_id);
|
||||||
|
let mut nl = gl.clone();
|
||||||
|
nl.layer.id = new_layer_id;
|
||||||
|
nl.children = gl.children.iter().map(|c| regen_any_layer(c, id_map)).collect();
|
||||||
|
AnyLayer::Group(nl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
// ──────────────────────── Pixel format conversion helpers ────────────────────
|
||||||
|
|
||||||
|
/// Convert straight-alpha RGBA bytes to premultiplied RGBA.
|
||||||
|
fn straight_to_premul(bytes: &[u8]) -> Vec<u8> {
|
||||||
|
bytes
|
||||||
|
.chunks_exact(4)
|
||||||
|
.flat_map(|p| {
|
||||||
|
let a = p[3];
|
||||||
|
if a == 0 {
|
||||||
|
[0u8, 0, 0, 0]
|
||||||
|
} else {
|
||||||
|
let scale = a as f32 / 255.0;
|
||||||
|
[
|
||||||
|
(p[0] as f32 * scale).round() as u8,
|
||||||
|
(p[1] as f32 * scale).round() as u8,
|
||||||
|
(p[2] as f32 * scale).round() as u8,
|
||||||
|
a,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy raster pixels to the system clipboard as an image.
|
/// Convert premultiplied RGBA bytes to straight-alpha RGBA.
|
||||||
///
|
fn premul_to_straight(bytes: &[u8]) -> Vec<u8> {
|
||||||
/// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes).
|
bytes
|
||||||
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors
|
.chunks_exact(4)
|
||||||
/// (arboard is a temporary integration point and will be replaced).
|
.flat_map(|p| {
|
||||||
pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) {
|
|
||||||
let Some(system) = self.system.as_mut() else { return };
|
|
||||||
// Unpremultiply: sRGB-premul → straight RGBA8 for the system clipboard.
|
|
||||||
let straight: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
|
|
||||||
let a = p[3];
|
let a = p[3];
|
||||||
if a == 0 {
|
if a == 0 {
|
||||||
[0u8, 0, 0, 0]
|
[0u8, 0, 0, 0]
|
||||||
|
|
@ -289,7 +497,103 @@ impl ClipboardManager {
|
||||||
a,
|
a,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}).collect();
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── PNG encoding helper ────────────────────────────
|
||||||
|
|
||||||
|
/// Encode sRGB premultiplied RGBA pixels as PNG bytes.
|
||||||
|
///
|
||||||
|
/// Returns `None` on encoding failure (logged to stderr).
|
||||||
|
pub(crate) fn encode_raster_as_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||||
|
use image::RgbaImage;
|
||||||
|
let img = RgbaImage::from_raw(width, height, premul_to_straight(pixels))?;
|
||||||
|
match crate::brush_engine::encode_png(&img) {
|
||||||
|
Ok(bytes) => Some(bytes),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("clipboard: PNG encode failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── ClipboardManager ─────────────────────────────
|
||||||
|
|
||||||
|
/// Manages clipboard operations with internal + system clipboard.
|
||||||
|
pub struct ClipboardManager {
|
||||||
|
/// Internal clipboard (preserves rich data without serialization loss).
|
||||||
|
internal: Option<ClipboardContent>,
|
||||||
|
/// System clipboard handle (lazy-initialized).
|
||||||
|
system: Option<arboard::Clipboard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipboardManager {
|
||||||
|
/// Create a new clipboard manager.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let system = arboard::Clipboard::new().ok();
|
||||||
|
Self { internal: None, system }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy content to the internal clipboard, the platform custom-MIME clipboard,
|
||||||
|
/// and the arboard text-fallback clipboard.
|
||||||
|
pub fn copy(&mut self, content: ClipboardContent) {
|
||||||
|
let json = serde_json::to_string(&content).unwrap_or_default();
|
||||||
|
|
||||||
|
// Build platform entries (custom MIME always present; PNG secondary for raster).
|
||||||
|
let mut entries: Vec<(&str, Vec<u8>)> =
|
||||||
|
vec![(LIGHTNINGBEAM_MIME, json.as_bytes().to_vec())];
|
||||||
|
if let ClipboardContent::RasterPixels { pixels, width, height } = &content {
|
||||||
|
if let Some(png) = encode_raster_as_png(pixels, *width, *height) {
|
||||||
|
entries.push(("image/png", png));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboard_platform::set(
|
||||||
|
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.internal = Some(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to paste content.
|
||||||
|
///
|
||||||
|
/// Checks the platform custom MIME type first. If our content is still on
|
||||||
|
/// the clipboard the internal cache is returned (avoids re-deserializing).
|
||||||
|
/// If another app has taken the clipboard since we last copied, the internal
|
||||||
|
/// cache is cleared and `None` is returned so the caller can try other
|
||||||
|
/// sources (e.g. `try_get_raster_image`).
|
||||||
|
pub fn paste(&mut self) -> Option<ClipboardContent> {
|
||||||
|
match clipboard_platform::get(&[LIGHTNINGBEAM_MIME]) {
|
||||||
|
Some((_, data)) => {
|
||||||
|
// Our MIME type is still on the clipboard — prefer the internal
|
||||||
|
// cache to avoid a round-trip through JSON.
|
||||||
|
if let Some(content) = &self.internal {
|
||||||
|
return Some(content.clone());
|
||||||
|
}
|
||||||
|
// Cross-process paste (internal cache absent): deserialize.
|
||||||
|
if let Ok(s) = std::str::from_utf8(&data) {
|
||||||
|
if let Ok(content) = serde_json::from_str::<ClipboardContent>(s) {
|
||||||
|
return Some(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Another app owns the clipboard — internal cache is stale.
|
||||||
|
self.internal = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy raster pixels to the system clipboard as an image.
|
||||||
|
///
|
||||||
|
/// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes).
|
||||||
|
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors.
|
||||||
|
pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) {
|
||||||
|
let Some(system) = self.system.as_mut() else { return };
|
||||||
|
let straight = premul_to_straight(pixels);
|
||||||
let img = arboard::ImageData {
|
let img = arboard::ImageData {
|
||||||
width: width as usize,
|
width: width as usize,
|
||||||
height: height as usize,
|
height: height as usize,
|
||||||
|
|
@ -303,39 +607,34 @@ impl ClipboardManager {
|
||||||
/// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if
|
/// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if
|
||||||
/// no image is available. Silently ignores errors.
|
/// no image is available. Silently ignores errors.
|
||||||
pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> {
|
pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> {
|
||||||
|
// On Linux arboard's get_image() does not reliably read clipboard images
|
||||||
|
// set by other apps on Wayland. Use clipboard_platform (wl-clipboard-rs /
|
||||||
|
// x11-clipboard) to read the raw image bytes then decode with the image crate.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let (_, data) = clipboard_platform::get(&[
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/bmp",
|
||||||
|
"image/tiff",
|
||||||
|
])?;
|
||||||
|
let img = image::load_from_memory(&data).ok()?.into_rgba8();
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
let premul = straight_to_premul(img.as_raw());
|
||||||
|
return Some((premul, width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS / Windows: arboard handles image clipboard natively.
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
let img = self.system.as_mut()?.get_image().ok()?;
|
let img = self.system.as_mut()?.get_image().ok()?;
|
||||||
let width = img.width as u32;
|
let premul = straight_to_premul(&img.bytes);
|
||||||
let height = img.height as u32;
|
Some((premul, img.width as u32, img.height as u32))
|
||||||
// Premultiply: straight RGBA8 → sRGB-premul.
|
|
||||||
let premul: Vec<u8> = img.bytes.chunks_exact(4).flat_map(|p| {
|
|
||||||
let a = p[3];
|
|
||||||
if a == 0 {
|
|
||||||
[0u8, 0, 0, 0]
|
|
||||||
} else {
|
|
||||||
let scale = a as f32 / 255.0;
|
|
||||||
[
|
|
||||||
(p[0] as f32 * scale).round() as u8,
|
|
||||||
(p[1] as f32 * scale).round() as u8,
|
|
||||||
(p[2] as f32 * scale).round() as u8,
|
|
||||||
a,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
Some((premul, width, height))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there's content available to paste
|
|
||||||
pub fn has_content(&mut self) -> bool {
|
|
||||||
if self.internal.is_some() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(system) = self.system.as_mut() {
|
|
||||||
if let Ok(text) = system.get_text() {
|
|
||||||
return text.starts_with(CLIPBOARD_PREFIX);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
/// Check if there is content available to paste.
|
||||||
|
pub fn has_content(&self) -> bool {
|
||||||
|
self.internal.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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;
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,13 @@ pub enum ToolState {
|
||||||
current: (i32, i32),
|
current: (i32, i32),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Moving an existing raster selection (and its floating pixels, if any).
|
||||||
|
MovingRasterSelection {
|
||||||
|
/// Canvas position of the pointer at the last processed event, used to
|
||||||
|
/// compute per-frame deltas.
|
||||||
|
last: (i32, i32),
|
||||||
|
},
|
||||||
|
|
||||||
/// Dragging selected objects
|
/// Dragging selected objects
|
||||||
DraggingSelection {
|
DraggingSelection {
|
||||||
start_pos: Point,
|
start_pos: Point,
|
||||||
|
|
|
||||||
|
|
@ -1417,7 +1417,8 @@ impl EditorApp {
|
||||||
5 => {
|
5 => {
|
||||||
// Painting focus -> RasterLayer
|
// Painting focus -> RasterLayer
|
||||||
use lightningbeam_core::raster_layer::RasterLayer;
|
use lightningbeam_core::raster_layer::RasterLayer;
|
||||||
let layer = RasterLayer::new("Raster 1");
|
let mut layer = RasterLayer::new("Raster 1");
|
||||||
|
layer.ensure_keyframe_at(self.playback_time, document.width as u32, document.height as u32);
|
||||||
document.root.add_child(AnyLayer::Raster(layer))
|
document.root.add_child(AnyLayer::Raster(layer))
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
@ -1924,6 +1925,11 @@ impl EditorApp {
|
||||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
||||||
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||||
|
|
||||||
|
// Ensure the canvas is allocated (empty Vec = blank transparent canvas).
|
||||||
|
let expected = (kf.width * kf.height * 4) as usize;
|
||||||
|
if kf.raw_pixels.len() != expected {
|
||||||
|
kf.raw_pixels.resize(expected, 0);
|
||||||
|
}
|
||||||
Self::composite_over(
|
Self::composite_over(
|
||||||
&mut kf.raw_pixels, kf.width, kf.height,
|
&mut kf.raw_pixels, kf.width, kf.height,
|
||||||
&float.pixels, float.width, float.height,
|
&float.pixels, float.width, float.height,
|
||||||
|
|
@ -1957,30 +1963,64 @@ impl EditorApp {
|
||||||
kf.raw_pixels = float.canvas_before;
|
kf.raw_pixels = float.canvas_before;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop (discard) the floating selection keeping the hole punched in the
|
||||||
|
/// canvas. Records a `RasterStrokeAction` for undo. Used by cut (Ctrl+X).
|
||||||
|
fn drop_raster_float(&mut self) {
|
||||||
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
|
use lightningbeam_core::actions::RasterStrokeAction;
|
||||||
|
|
||||||
|
let Some(float) = self.selection.raster_floating.take() else { return };
|
||||||
|
self.selection.raster_selection = None;
|
||||||
|
|
||||||
|
let doc = self.action_executor.document_mut();
|
||||||
|
let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&float.layer_id) else { return };
|
||||||
|
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||||
|
// raw_pixels already has the hole; record the undo action.
|
||||||
|
let canvas_after = kf.raw_pixels.clone();
|
||||||
|
let (w, h) = (kf.width, kf.height);
|
||||||
|
let action = RasterStrokeAction::new(
|
||||||
|
float.layer_id, float.time,
|
||||||
|
float.canvas_before, canvas_after,
|
||||||
|
w, h,
|
||||||
|
);
|
||||||
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
|
eprintln!("drop_raster_float: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy the current selection to the clipboard
|
/// Copy the current selection to the clipboard
|
||||||
fn clipboard_copy_selection(&mut self) {
|
fn clipboard_copy_selection(&mut self) {
|
||||||
use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType};
|
use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType};
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
|
|
||||||
// Raster selection takes priority when on a raster layer
|
// Raster selection takes priority when on a raster layer.
|
||||||
if let (Some(layer_id), Some(raster_sel)) = (
|
// If a floating selection exists (auto-lifted pixels), read directly from
|
||||||
self.active_layer_id,
|
// the float so we get exactly the lifted pixels.
|
||||||
self.selection.raster_selection.as_ref(),
|
if let Some(layer_id) = self.active_layer_id {
|
||||||
) {
|
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
|
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
||||||
|
if let Some(float) = &self.selection.raster_floating {
|
||||||
|
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
||||||
|
pixels: float.pixels.clone(),
|
||||||
|
width: float.width,
|
||||||
|
height: float.height,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if let Some(raster_sel) = self.selection.raster_selection.as_ref() {
|
||||||
if let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) {
|
||||||
if let Some(kf) = rl.keyframe_at(self.playback_time) {
|
if let Some(kf) = rl.keyframe_at(self.playback_time) {
|
||||||
let (pixels, w, h) = Self::extract_raster_selection(
|
let (pixels, w, h) = Self::extract_raster_selection(
|
||||||
&kf.raw_pixels, kf.width, kf.height, raster_sel,
|
&kf.raw_pixels, kf.width, kf.height, raster_sel,
|
||||||
);
|
);
|
||||||
self.clipboard_manager.try_set_raster_image(&pixels, w, h);
|
|
||||||
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
||||||
pixels, width: w, height: h,
|
pixels, width: w, height: h,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check what's selected: clip instances take priority, then shapes
|
// Check what's selected: clip instances take priority, then shapes
|
||||||
if !self.selection.clip_instances().is_empty() {
|
if !self.selection.clip_instances().is_empty() {
|
||||||
|
|
@ -2057,15 +2097,24 @@ impl EditorApp {
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
use lightningbeam_core::actions::RasterStrokeAction;
|
use lightningbeam_core::actions::RasterStrokeAction;
|
||||||
|
|
||||||
// Raster: commit any floating selection first, then erase the marquee region
|
// Raster: if a floating selection exists (auto-lifted), just drop it
|
||||||
|
// (keeps the hole). Otherwise commit any float then erase the marquee region.
|
||||||
|
if let Some(layer_id) = self.active_layer_id {
|
||||||
|
let document = self.action_executor.document();
|
||||||
|
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
||||||
|
if self.selection.raster_floating.is_some() {
|
||||||
|
self.drop_raster_float();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let (Some(layer_id), Some(raster_sel)) = (
|
if let (Some(layer_id), Some(raster_sel)) = (
|
||||||
self.active_layer_id,
|
self.active_layer_id,
|
||||||
self.selection.raster_selection.clone(),
|
self.selection.raster_selection.clone(),
|
||||||
) {
|
) {
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
||||||
// Committing a floating selection before erasing ensures any
|
|
||||||
// prior paste is baked in before we punch the new hole.
|
|
||||||
self.commit_raster_floating();
|
self.commit_raster_floating();
|
||||||
|
|
||||||
let document = self.action_executor.document_mut();
|
let document = self.action_executor.document_mut();
|
||||||
|
|
@ -2294,37 +2343,31 @@ impl EditorApp {
|
||||||
self.selection.add_clip_instance(id);
|
self.selection.add_clip_instance(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClipboardContent::Shapes { shapes } => {
|
ClipboardContent::VectorGeometry { .. } => {
|
||||||
let active_layer_id = match self.active_layer_id {
|
// TODO (Phase 2): paste DCEL subgraph once vector serialization is defined.
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add shapes to the active vector layer's keyframe
|
|
||||||
let document = self.action_executor.document_mut();
|
|
||||||
let layer = match document.get_layer_mut(&active_layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let vector_layer = match layer {
|
|
||||||
AnyLayer::Vector(vl) => vl,
|
|
||||||
_ => {
|
|
||||||
eprintln!("Cannot paste shapes: not a vector layer");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
ClipboardContent::Layers { .. } => {
|
||||||
|
// TODO: insert copied layers as siblings at the current selection point.
|
||||||
// TODO: DCEL - paste shapes not yet implemented
|
}
|
||||||
let _ = (vector_layer, shapes);
|
ClipboardContent::AudioNodes { .. } => {
|
||||||
|
// TODO: add nodes to the target layer's audio graph with new IDs and
|
||||||
|
// sync to the DAW backend.
|
||||||
}
|
}
|
||||||
ClipboardContent::MidiNotes { .. } => {
|
ClipboardContent::MidiNotes { .. } => {
|
||||||
// MIDI notes are pasted directly in the piano roll pane, not here
|
// MIDI notes are pasted directly in the piano roll pane, not here
|
||||||
}
|
}
|
||||||
ClipboardContent::RasterPixels { pixels, width, height } => {
|
ClipboardContent::RasterPixels { pixels, width, height } => {
|
||||||
let Some(layer_id) = self.active_layer_id else { return };
|
let Some(layer_id) = self.active_layer_id else { return };
|
||||||
|
|
||||||
|
// Commit any pre-existing floating selection FIRST so that
|
||||||
|
// canvas_before captures the fully-composited state (not the
|
||||||
|
// pre-commit state, which would corrupt the undo snapshot).
|
||||||
|
self.commit_raster_floating();
|
||||||
|
|
||||||
|
// Re-borrow the document after commit to get post-commit state.
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) else { return };
|
let layer = document.get_layer(&layer_id);
|
||||||
|
let Some(AnyLayer::Raster(rl)) = layer else { return };
|
||||||
let Some(kf) = rl.keyframe_at(self.playback_time) else { return };
|
let Some(kf) = rl.keyframe_at(self.playback_time) else { return };
|
||||||
|
|
||||||
// Paste position: top-left of the current raster selection if any,
|
// Paste position: top-left of the current raster selection if any,
|
||||||
|
|
@ -2334,15 +2377,12 @@ impl EditorApp {
|
||||||
.map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) })
|
.map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) })
|
||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
// Snapshot canvas before for undo on commit / restore on cancel.
|
// Snapshot canvas AFTER commit for correct undo on commit / restore on cancel.
|
||||||
let canvas_before = kf.raw_pixels.clone();
|
let canvas_before = kf.raw_pixels.clone();
|
||||||
let canvas_w = kf.width;
|
let canvas_w = kf.width;
|
||||||
let canvas_h = kf.height;
|
let canvas_h = kf.height;
|
||||||
drop(kf); // release immutable borrow before taking mutable
|
drop(kf); // release immutable borrow before taking mutable
|
||||||
|
|
||||||
// Commit any pre-existing floating selection before creating a new one.
|
|
||||||
self.commit_raster_floating();
|
|
||||||
|
|
||||||
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
|
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
|
||||||
self.selection.raster_floating = Some(RasterFloatingSelection {
|
self.selection.raster_floating = Some(RasterFloatingSelection {
|
||||||
pixels,
|
pixels,
|
||||||
|
|
@ -2786,6 +2826,13 @@ impl EditorApp {
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
MenuAction::Undo => {
|
MenuAction::Undo => {
|
||||||
|
// An uncommitted floating selection (paste not yet merged) lives
|
||||||
|
// outside the action stack. Cancelling it IS the undo — dismiss
|
||||||
|
// it and don't pop anything from the stack.
|
||||||
|
if self.selection.raster_floating.is_some() {
|
||||||
|
self.cancel_raster_floating();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let undo_succeeded = if let Some(ref controller_arc) = self.audio_controller {
|
let undo_succeeded = if let Some(ref controller_arc) = self.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
let mut backend_context = lightningbeam_core::action::BackendContext {
|
let mut backend_context = lightningbeam_core::action::BackendContext {
|
||||||
|
|
@ -3099,7 +3146,11 @@ impl EditorApp {
|
||||||
let layer_number = context_layers.len() + 1;
|
let layer_number = context_layers.len() + 1;
|
||||||
let layer_name = format!("Raster {}", layer_number);
|
let layer_name = format!("Raster {}", layer_number);
|
||||||
|
|
||||||
let layer = RasterLayer::new(layer_name);
|
let doc = self.action_executor.document();
|
||||||
|
let (doc_w, doc_h) = (doc.width as u32, doc.height as u32);
|
||||||
|
drop(doc);
|
||||||
|
let mut layer = RasterLayer::new(layer_name);
|
||||||
|
layer.ensure_keyframe_at(self.playback_time, doc_w, doc_h);
|
||||||
let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer))
|
let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer))
|
||||||
.with_target_clip(editing_clip_id);
|
.with_target_clip(editing_clip_id);
|
||||||
let _ = self.action_executor.execute(Box::new(action));
|
let _ = self.action_executor.execute(Box::new(action));
|
||||||
|
|
@ -5818,17 +5869,32 @@ impl eframe::App for EditorApp {
|
||||||
// Event::Copy/Cut/Paste instead of regular key events, so
|
// Event::Copy/Cut/Paste instead of regular key events, so
|
||||||
// check_shortcuts won't see them via key_pressed().
|
// check_shortcuts won't see them via key_pressed().
|
||||||
// Skip if a pane (e.g. piano roll) already handled the clipboard event.
|
// Skip if a pane (e.g. piano roll) already handled the clipboard event.
|
||||||
|
let mut clipboard_handled = clipboard_consumed;
|
||||||
if !clipboard_consumed {
|
if !clipboard_consumed {
|
||||||
for event in &i.events {
|
for event in &i.events {
|
||||||
match event {
|
match event {
|
||||||
egui::Event::Copy => {
|
egui::Event::Copy => {
|
||||||
self.handle_menu_action(MenuAction::Copy);
|
self.handle_menu_action(MenuAction::Copy);
|
||||||
|
clipboard_handled = true;
|
||||||
}
|
}
|
||||||
egui::Event::Cut => {
|
egui::Event::Cut => {
|
||||||
self.handle_menu_action(MenuAction::Cut);
|
self.handle_menu_action(MenuAction::Cut);
|
||||||
|
clipboard_handled = true;
|
||||||
}
|
}
|
||||||
egui::Event::Paste(_) => {
|
egui::Event::Paste(_) => {
|
||||||
self.handle_menu_action(MenuAction::Paste);
|
self.handle_menu_action(MenuAction::Paste);
|
||||||
|
clipboard_handled = true;
|
||||||
|
}
|
||||||
|
// When text/plain is absent from the system clipboard egui-winit
|
||||||
|
// falls through to a Key event instead of Event::Paste.
|
||||||
|
egui::Event::Key {
|
||||||
|
key: egui::Key::V,
|
||||||
|
pressed: true,
|
||||||
|
modifiers,
|
||||||
|
..
|
||||||
|
} if modifiers.ctrl || modifiers.command => {
|
||||||
|
self.handle_menu_action(MenuAction::Paste);
|
||||||
|
clipboard_handled = true;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -5837,12 +5903,17 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing
|
// Check menu shortcuts that use modifiers (Cmd+S, etc.) - allow even when typing
|
||||||
// But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano)
|
// But skip shortcuts without modifiers when keyboard input is claimed (e.g., virtual piano)
|
||||||
|
// Also skip clipboard actions (Copy/Cut/Paste) if already handled above to prevent
|
||||||
|
// double-firing when egui emits both Event::Key{V} and key_pressed(V) is true.
|
||||||
if let Some(action) = MenuSystem::check_shortcuts(i, Some(&self.keymap)) {
|
if let Some(action) = MenuSystem::check_shortcuts(i, Some(&self.keymap)) {
|
||||||
|
let is_clipboard = matches!(action, MenuAction::Copy | MenuAction::Cut | MenuAction::Paste);
|
||||||
// Only trigger if keyboard isn't claimed OR the shortcut uses modifiers
|
// Only trigger if keyboard isn't claimed OR the shortcut uses modifiers
|
||||||
if !wants_keyboard || i.modifiers.ctrl || i.modifiers.command || i.modifiers.alt || i.modifiers.shift {
|
if !wants_keyboard || i.modifiers.ctrl || i.modifiers.command || i.modifiers.alt || i.modifiers.shift {
|
||||||
|
if !(is_clipboard && clipboard_handled) {
|
||||||
self.handle_menu_action(action);
|
self.handle_menu_action(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check tool shortcuts (only if no text input is focused;
|
// Check tool shortcuts (only if no text input is focused;
|
||||||
// modifier guard is encoded in the bindings themselves — default tool bindings have no modifiers)
|
// modifier guard is encoded in the bindings themselves — default tool bindings have no modifiers)
|
||||||
|
|
|
||||||
|
|
@ -2284,6 +2284,10 @@ pub struct StagePane {
|
||||||
/// and updating raw_pixels, so the canvas lives one extra composite frame to
|
/// and updating raw_pixels, so the canvas lives one extra composite frame to
|
||||||
/// avoid a flash of the stale Vello scene.
|
/// avoid a flash of the stale Vello scene.
|
||||||
pending_canvas_removal: Option<uuid::Uuid>,
|
pending_canvas_removal: Option<uuid::Uuid>,
|
||||||
|
/// Selection outline saved at stroke mouse-down for post-readback pixel masking.
|
||||||
|
/// Pixels outside the selection are restored from `buffer_before` so strokes
|
||||||
|
/// only affect the area inside the selection outline.
|
||||||
|
stroke_clip_selection: Option<lightningbeam_core::selection::RasterSelection>,
|
||||||
/// Synthetic drag/click override for test mode replay (debug builds only)
|
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: Option<ReplayDragState>,
|
replay_override: Option<ReplayDragState>,
|
||||||
|
|
@ -2405,6 +2409,7 @@ impl StagePane {
|
||||||
pending_undo_before: None,
|
pending_undo_before: None,
|
||||||
painting_canvas: None,
|
painting_canvas: None,
|
||||||
pending_canvas_removal: None,
|
pending_canvas_removal: None,
|
||||||
|
stroke_clip_selection: None,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: None,
|
replay_override: None,
|
||||||
}
|
}
|
||||||
|
|
@ -4434,6 +4439,63 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lift the pixels enclosed by the current `raster_selection` into a
|
||||||
|
/// `RasterFloatingSelection`, punching a transparent hole in `raw_pixels`.
|
||||||
|
///
|
||||||
|
/// Call this immediately after a marquee / lasso selection is finalized so
|
||||||
|
/// that all downstream operations (drag-move, copy, cut, stroke-masking)
|
||||||
|
/// see a consistent `raster_floating` whenever a selection is active.
|
||||||
|
fn lift_selection_to_float(shared: &mut SharedPaneState) {
|
||||||
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
|
use lightningbeam_core::selection::RasterFloatingSelection;
|
||||||
|
|
||||||
|
// Clone the selection before any mutable borrows.
|
||||||
|
let Some(sel) = shared.selection.raster_selection.clone() else { return };
|
||||||
|
let Some(layer_id) = *shared.active_layer_id else { return };
|
||||||
|
let time = *shared.playback_time;
|
||||||
|
|
||||||
|
// Commit any existing float first (clears raster_selection — re-set below).
|
||||||
|
Self::commit_raster_floating_now(shared);
|
||||||
|
|
||||||
|
let doc = shared.action_executor.document_mut();
|
||||||
|
let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) else { return };
|
||||||
|
let Some(kf) = rl.keyframe_at_mut(time) else { return };
|
||||||
|
|
||||||
|
let canvas_before = kf.raw_pixels.clone();
|
||||||
|
let (x0, y0, x1, y1) = sel.bounding_rect();
|
||||||
|
let w = (x1 - x0).max(0) as u32;
|
||||||
|
let h = (y1 - y0).max(0) as u32;
|
||||||
|
if w == 0 || h == 0 { return; }
|
||||||
|
|
||||||
|
let mut float_pixels = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for row in 0..h {
|
||||||
|
let sy = y0 + row as i32;
|
||||||
|
if sy < 0 || sy >= kf.height as i32 { continue; }
|
||||||
|
for col in 0..w {
|
||||||
|
let sx = x0 + col as i32;
|
||||||
|
if sx < 0 || sx >= kf.width as i32 { continue; }
|
||||||
|
if !sel.contains_pixel(sx, sy) { continue; }
|
||||||
|
let si = ((sy as u32 * kf.width + sx as u32) * 4) as usize;
|
||||||
|
let di = ((row * w + col) * 4) as usize;
|
||||||
|
float_pixels[di..di + 4].copy_from_slice(&kf.raw_pixels[si..si + 4]);
|
||||||
|
kf.raw_pixels[si..si + 4].fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-set selection (commit_raster_floating_now cleared it) and create float.
|
||||||
|
shared.selection.raster_selection = Some(sel);
|
||||||
|
shared.selection.raster_floating = Some(RasterFloatingSelection {
|
||||||
|
pixels: float_pixels,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
x: x0,
|
||||||
|
y: y0,
|
||||||
|
layer_id,
|
||||||
|
time,
|
||||||
|
canvas_before,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`.
|
/// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`.
|
||||||
///
|
///
|
||||||
/// The actual pixel rendering happens on the GPU (compute shader). The CPU
|
/// The actual pixel rendering happens on the GPU (compute shader). The CPU
|
||||||
|
|
@ -4490,6 +4552,10 @@ impl StagePane {
|
||||||
// Mouse down: capture buffer_before, start stroke, compute first dab
|
// Mouse down: capture buffer_before, start stroke, compute first dab
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
|
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
|
||||||
|
// Save selection BEFORE commit clears it — used after readback to
|
||||||
|
// mask the stroke result so only pixels inside the outline change.
|
||||||
|
self.stroke_clip_selection = shared.selection.raster_selection.clone();
|
||||||
|
|
||||||
// Commit any floating selection synchronously so buffer_before and
|
// Commit any floating selection synchronously so buffer_before and
|
||||||
// the GPU canvas initial upload see the fully-composited canvas.
|
// the GPU canvas initial upload see the fully-composited canvas.
|
||||||
Self::commit_raster_floating_now(shared);
|
Self::commit_raster_floating_now(shared);
|
||||||
|
|
@ -4695,42 +4761,88 @@ impl StagePane {
|
||||||
let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32);
|
let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32);
|
||||||
|
|
||||||
if self.rsp_drag_started(response) {
|
if self.rsp_drag_started(response) {
|
||||||
Self::commit_raster_floating_now(shared);
|
|
||||||
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
||||||
|
let inside = shared.selection.raster_selection
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |sel| sel.contains_pixel(px, py));
|
||||||
|
|
||||||
|
if inside {
|
||||||
|
// Drag inside the selection — move it (and any floating pixels).
|
||||||
|
// As a safety net, lift the selection if no float exists yet.
|
||||||
|
if shared.selection.raster_floating.is_none() {
|
||||||
|
Self::lift_selection_to_float(shared);
|
||||||
|
}
|
||||||
|
*shared.tool_state = ToolState::MovingRasterSelection { last: (px, py) };
|
||||||
|
} else {
|
||||||
|
// Drag outside — start a new marquee (commit any floating first).
|
||||||
|
Self::commit_raster_floating_now(shared);
|
||||||
*shared.tool_state = ToolState::DrawingRasterMarquee {
|
*shared.tool_state = ToolState::DrawingRasterMarquee {
|
||||||
start: (px, py),
|
start: (px, py),
|
||||||
current: (px, py),
|
current: (px, py),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.rsp_dragged(response) {
|
if self.rsp_dragged(response) {
|
||||||
if let ToolState::DrawingRasterMarquee { start, ref mut current } = *shared.tool_state {
|
|
||||||
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
||||||
|
match *shared.tool_state {
|
||||||
|
ToolState::DrawingRasterMarquee { start, ref mut current } => {
|
||||||
*current = (px, py);
|
*current = (px, py);
|
||||||
let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w));
|
let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w));
|
||||||
let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h));
|
let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h));
|
||||||
shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1));
|
shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1));
|
||||||
}
|
}
|
||||||
|
ToolState::MovingRasterSelection { ref mut last } => {
|
||||||
|
let (dx, dy) = (px - last.0, py - last.1);
|
||||||
|
*last = (px, py);
|
||||||
|
// Shift the marquee.
|
||||||
|
if let Some(ref mut sel) = shared.selection.raster_selection {
|
||||||
|
*sel = match sel {
|
||||||
|
RasterSelection::Rect(x0, y0, x1, y1) =>
|
||||||
|
RasterSelection::Rect(*x0 + dx, *y0 + dy, *x1 + dx, *y1 + dy),
|
||||||
|
RasterSelection::Lasso(pts) =>
|
||||||
|
RasterSelection::Lasso(pts.iter().map(|(x, y)| (x + dx, y + dy)).collect()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Shift floating pixels if any.
|
||||||
|
if let Some(ref mut float) = shared.selection.raster_floating {
|
||||||
|
float.x += dx;
|
||||||
|
float.y += dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.rsp_drag_stopped(response) {
|
if self.rsp_drag_stopped(response) {
|
||||||
if let ToolState::DrawingRasterMarquee { start, current } = *shared.tool_state {
|
match *shared.tool_state {
|
||||||
|
ToolState::DrawingRasterMarquee { start, current } => {
|
||||||
let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w));
|
let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w));
|
||||||
let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h));
|
let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h));
|
||||||
shared.selection.raster_selection = if x1 > x0 && y1 > y0 {
|
if x1 > x0 && y1 > y0 {
|
||||||
Some(RasterSelection::Rect(x0, y0, x1, y1))
|
shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1));
|
||||||
|
Self::lift_selection_to_float(shared);
|
||||||
} else {
|
} else {
|
||||||
None
|
shared.selection.raster_selection = None;
|
||||||
};
|
|
||||||
*shared.tool_state = ToolState::Idle;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ToolState::MovingRasterSelection { .. } => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
*shared.tool_state = ToolState::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
if self.rsp_clicked(response) {
|
if self.rsp_clicked(response) {
|
||||||
// A click with no drag: commit float (clicked() fires on release, so
|
// A click with no drag: if outside the selection, commit any float and
|
||||||
// drag_started() may not have fired) then clear the selection.
|
// clear; if inside, do nothing (preserves the selection).
|
||||||
|
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
||||||
|
let inside = shared.selection.raster_selection
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |sel| sel.contains_pixel(px, py));
|
||||||
|
if !inside {
|
||||||
Self::commit_raster_floating_now(shared);
|
Self::commit_raster_floating_now(shared);
|
||||||
shared.selection.raster_selection = None;
|
shared.selection.raster_selection = None;
|
||||||
|
}
|
||||||
*shared.tool_state = ToolState::Idle;
|
*shared.tool_state = ToolState::Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4778,11 +4890,12 @@ impl StagePane {
|
||||||
|
|
||||||
if self.rsp_drag_stopped(response) {
|
if self.rsp_drag_stopped(response) {
|
||||||
if let ToolState::DrawingRasterLasso { ref points } = *shared.tool_state {
|
if let ToolState::DrawingRasterLasso { ref points } = *shared.tool_state {
|
||||||
shared.selection.raster_selection = if points.len() >= 3 {
|
if points.len() >= 3 {
|
||||||
Some(RasterSelection::Lasso(points.clone()))
|
shared.selection.raster_selection = Some(RasterSelection::Lasso(points.clone()));
|
||||||
|
Self::lift_selection_to_float(shared);
|
||||||
} else {
|
} else {
|
||||||
None
|
shared.selection.raster_selection = None;
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
*shared.tool_state = ToolState::Idle;
|
*shared.tool_state = ToolState::Idle;
|
||||||
}
|
}
|
||||||
|
|
@ -7427,11 +7540,28 @@ impl PaneRenderer for StagePane {
|
||||||
if let Some(readback) = results.remove(&self.instance_id) {
|
if let Some(readback) = results.remove(&self.instance_id) {
|
||||||
if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() {
|
if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() {
|
||||||
use lightningbeam_core::actions::RasterStrokeAction;
|
use lightningbeam_core::actions::RasterStrokeAction;
|
||||||
|
// If a selection was active at stroke-start, restore any pixels
|
||||||
|
// outside the selection outline to their pre-stroke values.
|
||||||
|
let canvas_after = match self.stroke_clip_selection.take() {
|
||||||
|
None => readback.pixels,
|
||||||
|
Some(sel) => {
|
||||||
|
let mut masked = readback.pixels;
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
if !sel.contains_pixel(x as i32, y as i32) {
|
||||||
|
let i = ((y * w + x) * 4) as usize;
|
||||||
|
masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
masked
|
||||||
|
}
|
||||||
|
};
|
||||||
let action = RasterStrokeAction::new(
|
let action = RasterStrokeAction::new(
|
||||||
layer_id,
|
layer_id,
|
||||||
time,
|
time,
|
||||||
buffer_before,
|
buffer_before,
|
||||||
readback.pixels.clone(),
|
canvas_after,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue